zotero/test/tests/server_connectorTest.js
Adomas Ven 8b77c96e97
Displays a browser window to clear captcha when saving attachments. (#3526)
- Currently enabled only for ScienceDirect. Can be enabled via a whitelist
- Matches the HiddenBrowser loaded HTML page for a captcha element. If
  the captcha element class changes, this will break (but the
  alternative is potentially displaying a captcha clearing window when
  something else that is not a captcha guard is loaded).
- Captcha clear timeout for 60s.
- Doesn't automatically switch focus back to the browser which intiated
  the item save via the Connector.
- Stores the cookies used to clear the captcha for future saves from the
  same domain. Discards Connector supplied User Agent, since CF bot
  detector checks UA header against actual UA behavior like TLS handshake
  and if the UA acts different to what it's supposed to, the bot
  challenge is not cleared.

Other changes:
- Adjusted the cookie sandbox to allow multiple cookie sandboxes to be
  active (and simplified some legacy code that was meant to cover a bug
  in old FX codebase).
- HiddenBrowser API changed to be Object oriented, translator tester
  in the translate repo will need to be updated after a merge (have the
  change ready).
- Improved Connector Server attachment progress handling
2023-12-27 04:43:50 -05:00

2843 lines
76 KiB
JavaScript

"use strict";
describe("Connector Server", function () {
Components.utils.import("resource://zotero-unit/httpd.js");
var win, connectorServerPath, testServerPath, httpd;
var testServerPort = 16213;
var snapshotHTML = "<html><head><title>Title</title><body>Body</body></html>";
before(function* () {
this.timeout(20000);
Zotero.Prefs.set("httpServer.enabled", true);
yield resetDB({
thisArg: this,
skipBundledFiles: true
});
yield Zotero.Translators.init();
win = yield loadZoteroPane();
connectorServerPath = 'http://127.0.0.1:' + Zotero.Prefs.get('httpServer.port');
});
beforeEach(function () {
// Alternate ports to prevent exceptions not catchable in JS
testServerPort += (testServerPort & 1) ? 1 : -1;
testServerPath = 'http://127.0.0.1:' + testServerPort;
httpd = new HttpServer();
httpd.start(testServerPort);
httpd.registerPathHandler(
"/snapshot",
{
handle: function (request, response) {
response.setStatusLine(null, 200, "OK");
response.write(snapshotHTML);
}
}
);
});
afterEach(function* () {
var defer = new Zotero.Promise.defer();
httpd.stop(() => defer.resolve());
yield defer.promise;
});
after(function () {
win.close();
});
describe('/connector/getTranslatorCode', function() {
it('should respond with translator code', function* () {
var code = 'function detectWeb() {}\nfunction doImport() {}';
var translator = buildDummyTranslator(4, code);
sinon.stub(Zotero.Translators, 'get').returns(translator);
var response = yield Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/getTranslatorCode",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
translatorID: "dummy-translator",
})
}
);
assert.isTrue(Zotero.Translators.get.calledWith('dummy-translator'));
let translatorCode = yield Zotero.Translators.getCodeForTranslator(translator);
assert.equal(response.response, translatorCode);
Zotero.Translators.get.restore();
})
});
describe("/connector/detect", function() {
it("should return relevant translators with proxies", function* () {
var code = 'function detectWeb() {return "newspaperArticle";}\nfunction doWeb() {}';
var translator = buildDummyTranslator("web", code, {target: "https://www.example.com/.*"});
sinon.stub(Zotero.Translators, 'getAllForType').resolves([translator]);
var response = yield Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/detect",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
uri: "https://www-example-com.proxy.example.com/article",
html: "<head><title>Owl</title></head><body><p>🦉</p></body>"
})
}
);
assert.equal(JSON.parse(response.response)[0].proxy.scheme, 'https://%h.proxy.example.com/%p');
Zotero.Translators.getAllForType.restore();
});
});
describe("/connector/saveItems", function () {
// TODO: Test cookies
it("should save a translated item to the current selected collection", function* () {
var collection = yield createDataObject('collection');
yield waitForItemsLoad(win);
var body = {
items: [
{
itemType: "newspaperArticle",
title: "Title",
creators: [
{
firstName: "First",
lastName: "Last",
creatorType: "author"
}
],
attachments: [
{
title: "Attachment",
url: `${testServerPath}/attachment`,
mimeType: "text/html"
}
]
}
],
uri: "http://example.com"
};
httpd.registerPathHandler(
"/attachment",
{
handle: function (request, response) {
response.setStatusLine(null, 200, "OK");
response.write("<html><head><title>Title</title><body>Body</body></html>");
}
}
);
var promise = waitForItemEvent('add');
var reqPromise = Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/saveItems",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(body)
}
);
// Check parent item
var ids = yield promise;
assert.lengthOf(ids, 1);
var item = Zotero.Items.get(ids[0]);
assert.equal(Zotero.ItemTypes.getName(item.itemTypeID), 'newspaperArticle');
assert.isTrue(collection.hasItem(item.id));
// Check attachment
promise = waitForItemEvent('add');
ids = yield promise;
assert.lengthOf(ids, 1);
item = Zotero.Items.get(ids[0]);
assert.isTrue(item.isImportedAttachment());
var req = yield reqPromise;
assert.equal(req.status, 201);
});
it("should switch to My Library if read-only library is selected", function* () {
var group = yield createGroup({
editable: false
});
yield selectLibrary(win, group.libraryID);
yield waitForItemsLoad(win);
var body = {
items: [
{
itemType: "newspaperArticle",
title: "Title",
creators: [
{
firstName: "First",
lastName: "Last",
creatorType: "author"
}
],
attachments: []
}
],
uri: "http://example.com"
};
var promise = waitForItemEvent('add');
var reqPromise = Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/saveItems",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(body),
successCodes: false
}
);
// My Library be selected, and the item should be in it
var ids = yield promise;
assert.equal(
win.ZoteroPane.collectionsView.getSelectedLibraryID(),
Zotero.Libraries.userLibraryID
);
assert.lengthOf(ids, 1);
var item = Zotero.Items.get(ids[0]);
assert.equal(item.libraryID, Zotero.Libraries.userLibraryID);
assert.equal(Zotero.ItemTypes.getName(item.itemTypeID), 'newspaperArticle');
var req = yield reqPromise;
assert.equal(req.status, 201);
});
it("should use the provided proxy to deproxify item url", function* () {
yield selectLibrary(win, Zotero.Libraries.userLibraryID);
yield waitForItemsLoad(win);
var body = {
items: [
{
itemType: "newspaperArticle",
title: "Title",
creators: [
{
firstName: "First",
lastName: "Last",
creatorType: "author"
}
],
attachments: [],
url: "https://www-example-com.proxy.example.com/path"
}
],
uri: "https://www-example-com.proxy.example.com/path",
proxy: {scheme: 'https://%h.proxy.example.com/%p'}
};
var promise = waitForItemEvent('add');
var req = yield Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/saveItems",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(body)
}
);
// Check item
var ids = yield promise;
assert.lengthOf(ids, 1);
var item = Zotero.Items.get(ids[0]);
assert.equal(item.getField('url'), 'https://www.example.com/path');
});
it("shouldn't return an attachment that isn't being saved", async function () {
Zotero.Prefs.set('automaticSnapshots', false);
await selectLibrary(win, Zotero.Libraries.userLibraryID);
await waitForItemsLoad(win);
var body = {
items: [
{
itemType: "webpage",
title: "Title",
creators: [],
attachments: [
{
url: "http://example.com/",
mimeType: "text/html"
}
],
url: "http://example.com/"
}
],
uri: "http://example.com/"
};
var req = await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/saveItems",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(body),
responseType: 'json'
}
);
Zotero.Prefs.clear('automaticSnapshots');
assert.equal(req.status, 201);
assert.lengthOf(req.response.items, 1);
assert.lengthOf(req.response.items[0].attachments, 0);
});
describe("PDF retrieval", function () {
var oaDOI = '10.1111/abcd';
var nonOADOI = '10.2222/bcde';
var pdfURL;
var badPDFURL;
var stub;
before(function () {
var origFunc = Zotero.HTTP.request.bind(Zotero.HTTP);
stub = sinon.stub(Zotero.HTTP, 'request');
stub.callsFake(function (method, url, options) {
// OA PDF lookup
if (url.startsWith(ZOTERO_CONFIG.SERVICES_URL)) {
let json = JSON.parse(options.body);
let response = [];
if (json.doi == oaDOI) {
response.push({
url: pdfURL,
version: 'submittedVersion'
});
}
return {
status: 200,
response
};
}
return origFunc(...arguments);
});
});
beforeEach(() => {
pdfURL = testServerPath + '/pdf';
badPDFURL = testServerPath + '/badpdf';
httpd.registerFile(
pdfURL.substr(testServerPath.length),
Zotero.File.pathToFile(OS.Path.join(getTestDataDirectory().path, 'test.pdf'))
);
// PDF URL that's actually an HTML page
httpd.registerFile(
badPDFURL.substr(testServerPath.length),
Zotero.File.pathToFile(OS.Path.join(getTestDataDirectory().path, 'test.html'))
);
});
afterEach(() => {
stub.resetHistory();
});
after(() => {
stub.restore();
});
it("should download a translated PDF", async function () {
var collection = await createDataObject('collection');
await waitForItemsLoad(win);
var sessionID = Zotero.Utilities.randomString();
// Save item
var itemAddPromise = waitForItemEvent('add');
var saveItemsReq = await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/saveItems",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
items: [
{
itemType: 'journalArticle',
title: 'Title',
DOI: nonOADOI,
attachments: [
{
title: "PDF",
url: pdfURL,
mimeType: 'application/pdf'
}
]
}
],
uri: 'http://website/article'
}),
responseType: 'json'
}
);
assert.equal(saveItemsReq.status, 201);
assert.lengthOf(saveItemsReq.response.items, 1);
// Translated attachment should show up in the initial response
assert.lengthOf(saveItemsReq.response.items[0].attachments, 1);
assert.notProperty(saveItemsReq.response.items[0], 'DOI');
assert.notProperty(saveItemsReq.response.items[0].attachments[0], 'progress');
// Check parent item
var ids = await itemAddPromise;
assert.lengthOf(ids, 1);
var item = Zotero.Items.get(ids[0]);
assert.equal(Zotero.ItemTypes.getName(item.itemTypeID), 'journalArticle');
assert.isTrue(collection.hasItem(item.id));
// Legacy endpoint should show 0
let attachmentProgressReq = await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/attachmentProgress",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify([saveItemsReq.response.items[0].attachments[0].id]),
responseType: 'json'
}
);
assert.equal(attachmentProgressReq.status, 200);
let progress = attachmentProgressReq.response;
assert.sameOrderedMembers(progress, [0]);
// Wait for the attachment to finish saving
itemAddPromise = waitForItemEvent('add');
var i = 0;
while (i < 3) {
let sessionProgressReq = await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/sessionProgress",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ sessionID }),
responseType: 'json'
}
);
assert.equal(sessionProgressReq.status, 200);
let response = sessionProgressReq.response;
assert.lengthOf(response.items, 1);
let item = response.items[0];
if (item.attachments.length) {
await Zotero.Promise.delay(10);
let attachments = item.attachments;
assert.lengthOf(attachments, 1);
let attachment = attachments[0];
switch (i) {
// Translated PDF in progress
case 0:
if (attachment.title == "PDF"
&& Number.isInteger(attachment.progress)
&& attachment.progress < 100) {
assert.isFalse(response.done);
i++;
}
continue;
// Translated PDF finished
case 1:
if (attachment.title == "PDF" && attachment.progress == 100) {
i++;
}
continue;
// done: true
case 2:
if (response.done) {
i++;
}
continue;
}
}
}
// Legacy endpoint should show 100
attachmentProgressReq = await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/attachmentProgress",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify([saveItemsReq.response.items[0].attachments[0].id]),
responseType: 'json'
}
);
assert.equal(attachmentProgressReq.status, 200);
progress = attachmentProgressReq.response;
assert.sameOrderedMembers(progress, [100]);
// Check attachment
var ids = await itemAddPromise;
assert.lengthOf(ids, 1);
item = Zotero.Items.get(ids[0]);
assert.isTrue(item.isImportedAttachment());
assert.equal(item.getField('title'), 'PDF');
});
it("should download open-access PDF if no PDF provided", async function () {
var collection = await createDataObject('collection');
await waitForItemsLoad(win);
var sessionID = Zotero.Utilities.randomString();
// Save item
var itemAddPromise = waitForItemEvent('add');
var saveItemsReq = await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/saveItems",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
items: [
{
itemType: 'journalArticle',
title: 'Title',
DOI: oaDOI,
attachments: []
}
],
uri: 'http://website/article'
}),
responseType: 'json'
}
);
assert.equal(saveItemsReq.status, 201);
assert.lengthOf(saveItemsReq.response.items, 1);
// Attachment shouldn't show up in the initial response
assert.lengthOf(saveItemsReq.response.items[0].attachments, 0);
// Check parent item
var ids = await itemAddPromise;
assert.lengthOf(ids, 1);
var item = Zotero.Items.get(ids[0]);
assert.equal(Zotero.ItemTypes.getName(item.itemTypeID), 'journalArticle');
assert.isTrue(collection.hasItem(item.id));
// Wait for the attachment to finish saving
itemAddPromise = waitForItemEvent('add');
var wasZero = false;
var was100 = false;
while (true) {
let sessionProgressReq = await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/sessionProgress",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ sessionID }),
responseType: 'json'
}
);
assert.equal(sessionProgressReq.status, 200);
let response = sessionProgressReq.response;
assert.typeOf(response.items, 'array');
assert.lengthOf(response.items, 1);
let item = response.items[0];
if (item.attachments.length) {
// 'progress' should have started at 0
if (item.attachments[0].progress === 0) {
wasZero = true;
}
else if (!was100 && item.attachments[0].progress == 100) {
if (response.done) {
break;
}
was100 = true;
}
else if (response.done) {
break;
}
}
assert.isFalse(response.done);
await Zotero.Promise.delay(10);
}
assert.isTrue(wasZero);
// Check attachment
var ids = await itemAddPromise;
assert.lengthOf(ids, 1);
item = Zotero.Items.get(ids[0]);
assert.isTrue(item.isImportedAttachment());
assert.equal(item.getField('title'), Zotero.getString('attachment.submittedVersion'));
});
it("should download open-access PDF if a translated PDF fails", async function () {
var collection = await createDataObject('collection');
await waitForItemsLoad(win);
var sessionID = Zotero.Utilities.randomString();
// Save item
var itemAddPromise = waitForItemEvent('add');
var saveItemsReq = await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/saveItems",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
items: [
{
itemType: 'journalArticle',
title: 'Title',
DOI: oaDOI,
attachments: [
{
title: "PDF",
url: badPDFURL,
mimeType: 'application/pdf'
}
]
}
],
uri: 'http://website/article'
}),
responseType: 'json'
}
);
assert.equal(saveItemsReq.status, 201);
assert.lengthOf(saveItemsReq.response.items, 1);
// Translated attachment should show up in the initial response
assert.lengthOf(saveItemsReq.response.items[0].attachments, 1);
assert.notProperty(saveItemsReq.response.items[0], 'DOI');
assert.notProperty(saveItemsReq.response.items[0].attachments[0], 'progress');
// Check parent item
var ids = await itemAddPromise;
assert.lengthOf(ids, 1);
var item = Zotero.Items.get(ids[0]);
assert.equal(Zotero.ItemTypes.getName(item.itemTypeID), 'journalArticle');
assert.isTrue(collection.hasItem(item.id));
// Legacy endpoint should show 0
let attachmentProgressReq = await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/attachmentProgress",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify([saveItemsReq.response.items[0].attachments[0].id]),
responseType: 'json'
}
);
assert.equal(attachmentProgressReq.status, 200);
let progress = attachmentProgressReq.response;
assert.sameOrderedMembers(progress, [0]);
// Wait for the attachment to finish saving
itemAddPromise = waitForItemEvent('add');
var i = 0;
while (i < 4) {
let sessionProgressReq = await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/sessionProgress",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ sessionID }),
responseType: 'json'
}
);
assert.equal(sessionProgressReq.status, 200);
let response = sessionProgressReq.response;
assert.lengthOf(response.items, 1);
let item = response.items[0];
if (item.attachments.length) {
let attachments = item.attachments;
assert.lengthOf(attachments, 1);
let attachment = attachments[0];
switch (i) {
// Translated PDF in progress
case 0:
if (attachment.title == "PDF"
&& Number.isInteger(attachment.progress)
&& attachment.progress < 100) {
assert.isFalse(response.done);
i++;
}
continue;
// OA PDF in progress
case 1:
if (attachment.title == Zotero.getString('findPDF.openAccessPDF')
&& Number.isInteger(attachment.progress)
&& attachment.progress < 100) {
assert.isFalse(response.done);
i++;
}
continue;
// OA PDF finished
case 2:
if (attachment.progress === 100) {
assert.equal(attachment.title, Zotero.getString('findPDF.openAccessPDF'));
i++;
}
continue;
// done: true
case 3:
if (response.done) {
i++;
}
continue;
}
}
await Zotero.Promise.delay(10);
}
// Legacy endpoint should show 100
attachmentProgressReq = await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/attachmentProgress",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify([saveItemsReq.response.items[0].attachments[0].id]),
responseType: 'json'
}
);
assert.equal(attachmentProgressReq.status, 200);
progress = attachmentProgressReq.response;
assert.sameOrderedMembers(progress, [100]);
// Check attachment
var ids = await itemAddPromise;
assert.lengthOf(ids, 1);
item = Zotero.Items.get(ids[0]);
assert.isTrue(item.isImportedAttachment());
assert.equal(item.getField('title'), Zotero.getString('attachment.submittedVersion'));
});
});
});
describe("/connector/saveSingleFile", function () {
it("should save a webpage item with /saveSnapshot", async function () {
var collection = await createDataObject('collection');
await waitForItemsLoad(win);
// Promise for item save
let promise = waitForItemEvent('add');
let testDataDirectory = getTestDataDirectory().path;
let indexPath = OS.Path.join(testDataDirectory, 'snapshot', 'index.html');
let title = Zotero.Utilities.randomString();
let sessionID = Zotero.Utilities.randomString();
let payload = {
sessionID,
url: "http://example.com/test",
title,
singleFile: true
};
await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/saveSnapshot",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
}
);
// Await item save
let parentIDs = await promise;
// Check parent item
assert.lengthOf(parentIDs, 1);
var item = Zotero.Items.get(parentIDs[0]);
assert.equal(Zotero.ItemTypes.getName(item.itemTypeID), 'webpage');
assert.isTrue(collection.hasItem(item.id));
assert.equal(item.getField('title'), title);
// Promise for attachment save
promise = waitForItemEvent('add');
let body = JSON.stringify(Object.assign(payload, {
snapshotContent: await Zotero.File.getContentsAsync(indexPath)
}));
await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/saveSingleFile",
{
headers: {
"Content-Type": "application/json"
},
body
}
);
// Await attachment save
let attachmentIDs = await promise;
// Check attachment
assert.lengthOf(attachmentIDs, 1);
item = Zotero.Items.get(attachmentIDs[0]);
assert.isTrue(item.isImportedAttachment());
assert.equal(item.getField('title'), title);
// Check attachment html file
let attachmentDirectory = Zotero.Attachments.getStorageDirectory(item).path;
let path = OS.Path.join(attachmentDirectory, 'test.html');
assert.isTrue(await OS.File.exists(path));
let contents = await Zotero.File.getContentsAsync(path);
let expectedContents = await Zotero.File.getContentsAsync(indexPath);
assert.equal(contents, expectedContents);
});
it("should save a webpage item with /saveItems", async function () {
let collection = await createDataObject('collection');
await waitForItemsLoad(win);
let title = Zotero.Utilities.randomString();
let sessionID = Zotero.Utilities.randomString();
let payload = {
sessionID: sessionID,
items: [
{
itemType: "newspaperArticle",
title: title,
creators: [
{
firstName: "First",
lastName: "Last",
creatorType: "author"
}
],
attachments: [
{
title: "Snapshot",
url: `${testServerPath}/attachment`,
mimeType: "text/html",
singleFile: true
}
]
}
],
uri: "http://example.com"
};
let promise = waitForItemEvent('add');
let req = await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/saveItems",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
}
);
assert.equal(req.status, 201);
// Check parent item
let itemIDs = await promise;
assert.lengthOf(itemIDs, 1);
let item = Zotero.Items.get(itemIDs[0]);
assert.equal(Zotero.ItemTypes.getName(item.itemTypeID), 'newspaperArticle');
assert.isTrue(collection.hasItem(item.id));
// Promise for attachment save
promise = waitForItemEvent('add');
let testDataDirectory = getTestDataDirectory().path;
let indexPath = OS.Path.join(testDataDirectory, 'snapshot', 'index.html');
let body = JSON.stringify(Object.assign(payload, {
snapshotContent: await Zotero.File.getContentsAsync(indexPath)
}));
req = await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/saveSingleFile",
{
headers: {
"Content-Type": "application/json"
},
body
}
);
assert.equal(req.status, 201);
// Await attachment save
let attachmentIDs = await promise;
// Check attachment
assert.lengthOf(attachmentIDs, 1);
item = Zotero.Items.get(attachmentIDs[0]);
assert.isTrue(item.isImportedAttachment());
assert.equal(item.getField('title'), 'Snapshot');
// Check attachment html file
let attachmentDirectory = Zotero.Attachments.getStorageDirectory(item).path;
let path = OS.Path.join(attachmentDirectory, 'attachment.html');
assert.isTrue(await OS.File.exists(path));
let contents = await Zotero.File.getContentsAsync(path);
let expectedContents = await Zotero.File.getContentsAsync(indexPath);
assert.equal(contents, expectedContents);
});
it("should override SingleFileZ from old connector in /saveSnapshot", async function () {
Components.utils.import("resource://gre/modules/FileUtils.jsm");
var collection = await createDataObject('collection');
await waitForItemsLoad(win);
// Promise for item save
let promise = waitForItemEvent('add');
let testDataDirectory = getTestDataDirectory().path;
let indexPath = OS.Path.join(testDataDirectory, 'snapshot', 'index.html');
let prefix = '/' + Zotero.Utilities.randomString() + '/';
let uri = OS.Path.join(getTestDataDirectory().path, 'snapshot');
httpd.registerDirectory(prefix, new FileUtils.File(uri));
let title = Zotero.Utilities.randomString();
let sessionID = Zotero.Utilities.randomString();
let payload = {
sessionID,
url: testServerPath + prefix + 'index.html',
title,
singleFile: true
};
await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/saveSnapshot",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
}
);
// Await item save
let parentIDs = await promise;
// Check parent item
assert.lengthOf(parentIDs, 1);
var item = Zotero.Items.get(parentIDs[0]);
assert.equal(Zotero.ItemTypes.getName(item.itemTypeID), 'webpage');
assert.isTrue(collection.hasItem(item.id));
assert.equal(item.getField('title'), title);
// Promise for attachment save
promise = waitForItemEvent('add');
let body = new FormData();
let uuid = 'binary-' + Zotero.Utilities.randomString();
body.append("payload", JSON.stringify(Object.assign(payload, {
pageData: {
content: await Zotero.File.getContentsAsync(indexPath),
resources: {
images: [
{
name: "img.gif",
content: uuid,
binary: true
}
]
}
}
})));
await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/saveSingleFile",
{
headers: {
"Content-Type": "multipart/form-data",
"zotero-allowed-request": "true"
},
body
}
);
// Await attachment save
let attachmentIDs = await promise;
// Check attachment
assert.lengthOf(attachmentIDs, 1);
item = Zotero.Items.get(attachmentIDs[0]);
assert.isTrue(item.isImportedAttachment());
assert.equal(item.getField('title'), title);
// Check attachment html file
let attachmentDirectory = Zotero.Attachments.getStorageDirectory(item).path;
let path = OS.Path.join(attachmentDirectory, item.attachmentFilename);
assert.isTrue(await OS.File.exists(path));
let contents = await Zotero.File.getContentsAsync(path);
assert.match(contents, /^<html><!--\n Page saved with SingleFile \n url:/);
});
it("should override SingleFileZ from old connector in /saveItems", async function () {
let collection = await createDataObject('collection');
await waitForItemsLoad(win);
let prefix = '/' + Zotero.Utilities.randomString() + '/';
let uri = OS.Path.join(getTestDataDirectory().path, 'snapshot');
httpd.registerDirectory(prefix, new FileUtils.File(uri));
let title = Zotero.Utilities.randomString();
let sessionID = Zotero.Utilities.randomString();
let payload = {
sessionID: sessionID,
items: [
{
itemType: "newspaperArticle",
title: title,
creators: [
{
firstName: "First",
lastName: "Last",
creatorType: "author"
}
],
attachments: [
{
title: "Snapshot",
url: testServerPath + prefix + 'index.html',
mimeType: "text/html",
singleFile: true
}
]
}
],
uri: "http://example.com"
};
let promise = waitForItemEvent('add');
let req = await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/saveItems",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
}
);
assert.equal(req.status, 201);
// Check parent item
let itemIDs = await promise;
assert.lengthOf(itemIDs, 1);
let item = Zotero.Items.get(itemIDs[0]);
assert.equal(Zotero.ItemTypes.getName(item.itemTypeID), 'newspaperArticle');
assert.isTrue(collection.hasItem(item.id));
// Promise for attachment save
promise = waitForItemEvent('add');
let testDataDirectory = getTestDataDirectory().path;
let indexPath = OS.Path.join(testDataDirectory, 'snapshot', 'index.html');
let body = new FormData();
let uuid = 'binary-' + Zotero.Utilities.randomString();
body.append("payload", JSON.stringify(Object.assign(payload, {
pageData: {
content: 'Foobar content',
resources: {
images: [
{
name: "img.gif",
content: uuid,
binary: true
}
]
}
}
})));
req = await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/saveSingleFile",
{
headers: {
"Content-Type": "multipart/form-data",
"zotero-allowed-request": "true"
},
body
}
);
assert.equal(req.status, 201);
// Await attachment save
let attachmentIDs = await promise;
// Check attachment
assert.lengthOf(attachmentIDs, 1);
item = Zotero.Items.get(attachmentIDs[0]);
assert.isTrue(item.isImportedAttachment());
assert.equal(item.getField('title'), 'Snapshot');
// Check attachment html file
let attachmentDirectory = Zotero.Attachments.getStorageDirectory(item).path;
let path = OS.Path.join(attachmentDirectory, item.attachmentFilename);
assert.isTrue(await OS.File.exists(path));
let contents = await Zotero.File.getContentsAsync(path);
assert.match(contents, /^<html><!--\n Page saved with SingleFile \n url:/);
});
it("should handle race condition with /saveItems", async function () {
let collection = await createDataObject('collection');
await waitForItemsLoad(win);
let pdfURL = testServerPath + '/pdf';
let nonOADOI = '10.2222/bcde';
// Promise for item saving
let parentIDs, attachmentIDs1, attachmentIDs2;
let promise = waitForItemEvent('add').then(function (ids) {
parentIDs = ids;
return waitForItemEvent('add').then(function (ids) {
attachmentIDs1 = ids;
return waitForItemEvent('add').then(function (ids) {
attachmentIDs2 = ids;
});
});
});
// Promise for snapshot having been saved
let singleFileResolve;
let singleFileDone = new Zotero.Promise(function (resolve, reject) {
singleFileResolve = resolve;
});
// Special handler to delay writing of file response for 5 seconds to allow
// `saveSingleFile` request to finish first before getting PDF
httpd.registerPathHandler(
'/pdf',
{
handle: async function (request, response) {
response.setStatusLine(null, 200, "OK");
let file = Zotero.File.pathToFile(OS.Path.join(getTestDataDirectory().path, 'test.pdf'));
response.processAsync();
// Delay the PDF processing (simulates a long network request) so that
// the SingleFile request below completes first.
await singleFileDone;
httpd._handler._writeFileResponse(request, file, response, 0, file.fileSize);
}
}
);
// Setup our `saveItems` and payload and call connector server
let title = Zotero.Utilities.randomString();
let sessionID = Zotero.Utilities.randomString();
let payload = {
sessionID,
items: [
{
itemType: 'journalArticle',
title: title,
DOI: nonOADOI,
attachments: [
{
title: "PDF",
url: pdfURL,
mimeType: 'application/pdf'
},
{
title: "Snapshot",
url: `${testServerPath}/attachment`,
mimeType: "text/html",
singleFile: true
}
]
}
],
uri: "http://example.com"
};
let req = await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/saveItems",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
}
);
assert.equal(req.status, 201);
// Now setup and call our `saveSingleFile` to save snapshot attachment
let testDataDirectory = getTestDataDirectory().path;
let indexPath = OS.Path.join(testDataDirectory, 'snapshot', 'index.html');
let body = JSON.stringify(Object.assign(payload, {
snapshotContent: await Zotero.File.getContentsAsync(indexPath)
}));
req = await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/saveSingleFile",
{
headers: {
"Content-Type": "application/json"
},
body
}
);
assert.equal(req.status, 201);
// Trigger PDF saving to complete now that SingleFile is done.
singleFileResolve();
// Await all item saves
await promise;
// Once the PDF is saved, if the bug exists, the snapshot will saved again.
// Once that is completed, then the session will be marked as done so we
// wait for that to occur here. Then we can proceed to ensure we have the
// proper number of items.
let savingDone = false;
while (!savingDone) {
// eslint-disable-next-line no-await-in-loop
req = await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/sessionProgress",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ sessionID })
}
);
savingDone = JSON.parse(req.response).done;
if (!savingDone) {
// eslint-disable-next-line no-await-in-loop
await Zotero.Promise.delay(1000);
}
}
// Check parent item
assert.lengthOf(parentIDs, 1);
let item = Zotero.Items.get(parentIDs[0]);
assert.equal(Zotero.ItemTypes.getName(item.itemTypeID), 'journalArticle');
assert.isTrue(collection.hasItem(item.id));
// Ensure we only have one snapshot and one PDF - this is the critical test
assert.equal(item.numChildren(), 2);
// Snapshot is saved first
assert.lengthOf(attachmentIDs1, 1);
item = Zotero.Items.get(attachmentIDs1[0]);
assert.isTrue(item.isImportedAttachment());
assert.equal(item.getField('title'), 'Snapshot');
// Double check snapshot html file has content
let attachmentDirectory = Zotero.Attachments.getStorageDirectory(item).path;
let path = OS.Path.join(attachmentDirectory, 'attachment.html');
assert.isTrue(await OS.File.exists(path));
let contents = await Zotero.File.getContentsAsync(path);
let expectedContents = await Zotero.File.getContentsAsync(indexPath);
assert.equal(contents, expectedContents);
// Then PDF is saved second
assert.lengthOf(attachmentIDs2, 1);
item = Zotero.Items.get(attachmentIDs2[0]);
assert.isTrue(item.isImportedAttachment());
assert.equal(item.getField('title'), 'PDF');
});
});
describe("/connector/saveSnapshot", function () {
it("should save a webpage item and snapshot to the current selected collection", function* () {
var collection = yield createDataObject('collection');
yield waitForItemsLoad(win);
// saveSnapshot saves parent and child before returning
var ids1, ids2;
var promise = waitForItemEvent('add').then(function (ids) {
ids1 = ids;
return waitForItemEvent('add').then(function (ids) {
ids2 = ids;
});
});
var file = getTestDataDirectory();
file.append('snapshot');
file.append('index.html');
httpd.registerFile("/test", file);
yield Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/saveSnapshot",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
url: `${testServerPath}/test`,
html: "<html><head><title>Title</title><body>Body</body></html>"
})
}
);
assert.isTrue(promise.isFulfilled());
// Check parent item
assert.lengthOf(ids1, 1);
var item = Zotero.Items.get(ids1[0]);
assert.equal(Zotero.ItemTypes.getName(item.itemTypeID), 'webpage');
assert.isTrue(collection.hasItem(item.id));
assert.equal(item.getField('title'), 'Title');
// Check attachment
assert.lengthOf(ids2, 1);
item = Zotero.Items.get(ids2[0]);
assert.isTrue(item.isImportedAttachment());
assert.equal(item.getField('title'), 'Title');
});
it("should save a PDF to the current selected collection and retrieve metadata", async function () {
var collection = await createDataObject('collection');
await waitForItemsLoad(win);
var file = getTestDataDirectory();
file.append('test.pdf');
httpd.registerFile("/test.pdf", file);
var promise = waitForItemEvent('add');
var origRequest = Zotero.HTTP.request.bind(Zotero.HTTP);
var called = 0;
var stub = sinon.stub(Zotero.HTTP, 'request').callsFake(function (method, url, options) {
// Forward saveSnapshot request
if (url.endsWith('saveSnapshot')) {
return origRequest(...arguments);
}
// Fake recognizer response
return Zotero.Promise.resolve({
getResponseHeader: () => {},
responseText: JSON.stringify({
title: 'Test',
authors: []
})
});
});
let response = await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/saveSnapshot",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
url: testServerPath + "/test.pdf",
pdf: true,
singleFile: true
})
}
);
let json = JSON.parse(response.responseText);
assert.propertyVal(json, 'saveSingleFile', false);
var ids = await promise;
assert.lengthOf(ids, 1);
var attachment = Zotero.Items.get(ids[0]);
assert.isTrue(attachment.isImportedAttachment());
assert.equal(attachment.attachmentContentType, 'application/pdf');
assert.isTrue(collection.hasItem(attachment.id));
await waitForItemEvent('add');
await waitForItemEvent('modify');
await waitForItemEvent('modify');
assert.isFalse(attachment.isTopLevelItem());
stub.restore();
});
it("should switch to My Library if a read-only library is selected", function* () {
var group = yield createGroup({
editable: false
});
yield selectLibrary(win, group.libraryID);
yield waitForItemsLoad(win);
var promise = waitForItemEvent('add');
var reqPromise = Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/saveSnapshot",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
url: testServerPath + '/snapshot',
html: snapshotHTML
}),
successCodes: false
}
);
// My Library be selected, and the item should be in it
var ids = yield promise;
assert.equal(
win.ZoteroPane.collectionsView.getSelectedLibraryID(),
Zotero.Libraries.userLibraryID
);
assert.lengthOf(ids, 1);
var item = Zotero.Items.get(ids[0]);
assert.equal(item.libraryID, Zotero.Libraries.userLibraryID);
var req = yield reqPromise;
assert.equal(req.status, 201);
});
});
describe("/connector/savePage", function() {
before(async function () {
await selectLibrary(win);
await waitForItemsLoad(win);
});
it("should return 500 if no translator available for page", function* () {
var xmlhttp = yield Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/savePage",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
uri: "http://example.com",
html: "<html><head><title>Title</title><body>Body</body></html>"
}),
successCodes: false
}
);
assert.equal(xmlhttp.status, 500);
});
it("should translate a page if translators are available", function* () {
var html = Zotero.File.getContentsFromURL(getTestDataUrl('coins.html'));
var promise = waitForItemEvent('add');
var xmlhttp = yield Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/savePage",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
uri: "https://example.com/test",
html
}),
successCodes: false
}
);
let ids = yield promise;
var item = Zotero.Items.get(ids[0]);
var title = "Test Page";
assert.equal(JSON.parse(xmlhttp.responseText).items[0].title, title);
assert.equal(item.getField('title'), title);
assert.equal(xmlhttp.status, 201);
});
});
describe("/connector/updateSession", function () {
it("should update collections and tags of item saved via /saveItems", async function () {
var collection1 = await createDataObject('collection');
var collection2 = await createDataObject('collection');
await waitForItemsLoad(win);
var sessionID = Zotero.Utilities.randomString();
var body = {
sessionID,
items: [
{
itemType: "newspaperArticle",
title: "Title",
creators: [
{
firstName: "First",
lastName: "Last",
creatorType: "author"
}
],
attachments: [
{
title: "Attachment",
url: `${testServerPath}/attachment`,
mimeType: "text/html"
}
]
}
],
uri: "http://example.com"
};
httpd.registerPathHandler(
"/attachment",
{
handle: function (request, response) {
response.setStatusLine(null, 200, "OK");
response.write("<html><head><title>Title</title><body>Body</body></html>");
}
}
);
var reqPromise = Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/saveItems",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(body)
}
);
var ids = await waitForItemEvent('add');
var item = Zotero.Items.get(ids[0]);
assert.isTrue(collection2.hasItem(item.id));
await waitForItemEvent('add');
var req = await reqPromise;
assert.equal(req.status, 201);
// Update saved item
var req = await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/updateSession",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
target: collection1.treeViewID,
tags: "A, B"
})
}
);
assert.equal(req.status, 200);
assert.isTrue(collection1.hasItem(item.id));
assert.isTrue(item.hasTag("A"));
assert.isTrue(item.hasTag("B"));
});
it("should update collections and tags of PDF saved via /saveSnapshot", async function () {
var sessionID = Zotero.Utilities.randomString();
var collection1 = await createDataObject('collection');
var collection2 = await createDataObject('collection');
await waitForItemsLoad(win);
var file = getTestDataDirectory();
file.append('test.pdf');
httpd.registerFile("/test.pdf", file);
var ids;
var promise = waitForItemEvent('add');
var reqPromise = Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/saveSnapshot",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
url: testServerPath + "/test.pdf",
pdf: true
})
}
);
var ids = await promise;
var item = Zotero.Items.get(ids[0]);
assert.isTrue(collection2.hasItem(item.id));
var req = await reqPromise;
assert.equal(req.status, 201);
// Update saved item
var req = await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/updateSession",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
target: collection1.treeViewID,
tags: "A, B"
})
}
);
assert.equal(req.status, 200);
assert.isTrue(collection1.hasItem(item.id));
assert.isTrue(item.hasTag("A"));
assert.isTrue(item.hasTag("B"));
});
it("should update collections and tags of webpage saved via /saveSnapshot", async function () {
var sessionID = Zotero.Utilities.randomString();
var collection1 = await createDataObject('collection');
var collection2 = await createDataObject('collection');
await waitForItemsLoad(win);
// saveSnapshot saves parent and child before returning
var ids1, ids2;
var promise = waitForItemEvent('add').then(function (ids) {
ids1 = ids;
return waitForItemEvent('add').then(function (ids) {
ids2 = ids;
});
});
await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/saveSnapshot",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
url: testServerPath + '/snapshot',
html: "<html><head><title>Title</title><body>Body</body></html>"
})
}
);
assert.isTrue(promise.isFulfilled());
var item = Zotero.Items.get(ids1[0]);
// Update saved item
var req = await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/updateSession",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
target: collection1.treeViewID,
tags: "A, B"
})
}
);
assert.equal(req.status, 200);
assert.isTrue(collection1.hasItem(item.id));
assert.isTrue(item.hasTag("A"));
assert.isTrue(item.hasTag("B"));
});
it("should move item saved via /saveItems to another library", async function () {
let addItemsSpy = sinon.spy(Zotero.Server.Connector.SaveSession.prototype, 'addItems');
var group = await createGroup({ editable: true, filesEditable: false });
await selectLibrary(win);
await waitForItemsLoad(win);
var sessionID = Zotero.Utilities.randomString();
var body = {
sessionID,
items: [
{
itemType: "newspaperArticle",
title: "Title",
attachments: [
{
title: "Attachment",
url: `${testServerPath}/attachment`,
mimeType: "text/html"
}
]
}
],
uri: "http://example.com"
};
httpd.registerPathHandler(
"/attachment",
{
handle: function (request, response) {
response.setStatusLine(null, 200, "OK");
response.write("<html><head><title>Title</title><body>Body</body></html>");
}
}
);
var reqPromise = Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/saveItems",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(body)
}
);
var ids1 = await waitForItemEvent('add');
var item1 = Zotero.Items.get(ids1[0]);
// Attachment
await waitForItemEvent('add');
// There's an additional addItems call in saveItems that is not async returned and runs
// after attachment notifier add event callbacks are run, so we have to do some
// hacky waiting here, otherwise we get some crazy race-conditions due to
// collection changing being debounced
let callCount = addItemsSpy.callCount;
while (addItemsSpy.callCount <= callCount) {
await Zotero.Promise.delay(50);
}
await addItemsSpy.lastCall.returnValue;
var req = await reqPromise;
assert.equal(req.status, 201);
// Move item to group without file attachment
reqPromise = Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/updateSession",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
target: group.treeViewID
})
}
);
var ids2 = await waitForItemEvent('add');
var item2 = Zotero.Items.get(ids2[0]);
req = await reqPromise;
assert.equal(req.status, 200);
assert.isFalse(Zotero.Items.exists(item1.id));
assert.equal(item2.libraryID, group.libraryID);
assert.equal(item2.numAttachments(), 0);
// Move back to My Library and resave attachment
reqPromise = Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/updateSession",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
target: Zotero.Libraries.userLibrary.treeViewID
})
}
);
var ids3 = await waitForItemEvent('add');
var item3 = Zotero.Items.get(ids3[0]);
// Attachment
await waitForItemEvent('add');
req = await reqPromise;
assert.equal(req.status, 200);
assert.isFalse(Zotero.Items.exists(item2.id));
assert.equal(item3.libraryID, Zotero.Libraries.userLibraryID);
assert.equal(item3.numAttachments(), 1);
addItemsSpy.restore();
});
it("should move item saved via /saveSnapshot to another library", async function () {
var group = await createGroup({ editable: true, filesEditable: false });
await selectLibrary(win);
await waitForItemsLoad(win);
var sessionID = Zotero.Utilities.randomString();
// saveSnapshot saves parent and child before returning
var ids1;
var promise = waitForItemEvent('add').then(function (ids) {
ids1 = ids;
return waitForItemEvent('add').then(function (ids) {
ids1 = ids1.concat(ids);
});
});
await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/saveSnapshot",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
url: testServerPath + '/snapshot',
html: "<html><head><title>Title</title><body>Body</body></html>"
})
}
);
assert.isTrue(promise.isFulfilled());
var item1 = Zotero.Items.get(ids1[0]);
// Move item to group without file attachment
var reqPromise = Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/updateSession",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
target: group.treeViewID
})
}
);
var ids2 = await waitForItemEvent('add');
var item2 = Zotero.Items.get(ids2[0]);
var req = await reqPromise;
assert.equal(req.status, 200);
assert.isFalse(Zotero.Items.exists(item1.id));
assert.equal(item2.libraryID, group.libraryID);
assert.equal(item2.numAttachments(), 0);
// Move back to My Library and resave attachment
reqPromise = Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/updateSession",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
target: Zotero.Libraries.userLibrary.treeViewID
})
}
);
var ids3 = await waitForItemEvent('add');
var item3 = Zotero.Items.get(ids3[0]);
await waitForItemEvent('add');
req = await reqPromise;
assert.equal(req.status, 200);
assert.isFalse(Zotero.Items.exists(item2.id));
assert.equal(item3.libraryID, Zotero.Libraries.userLibraryID);
assert.equal(item3.numAttachments(), 1);
});
it("should save item saved via /saveSnapshot and /saveSingleFile to another library", async function () {
let group = await createGroup({ editable: true, filesEditable: false });
await selectLibrary(win);
await waitForItemsLoad(win);
let sessionID = Zotero.Utilities.randomString();
// Wait for /saveSnapshot and /saveSingleFile to items
let ids1, ids2;
let promise = waitForItemEvent('add').then(function (ids) {
ids1 = ids;
return waitForItemEvent('add').then(function (ids) {
ids2 = ids;
});
});
let title = Zotero.Utilities.randomString();
let payload = {
sessionID,
url: "http://example.com/test",
title,
singleFile: true
};
await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/saveSnapshot",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
}
);
let body = JSON.stringify(Object.assign(payload, {
snapshotContent: '<html><head><title>Title</title><body>Body'
}));
let req = await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/saveSingleFile",
{
headers: {
"Content-Type": "application/json"
},
body
}
);
// Check an item exists
await promise;
assert.equal(req.status, 201);
let item1 = Zotero.Items.get(ids1[0]);
assert.equal(item1.numAttachments(), 1);
// Check attachment item
let item2 = Zotero.Items.get(ids2[0]);
assert.equal(item2.libraryID, Zotero.Libraries.userLibraryID);
assert.equal(item2.parentItemID, item1.id);
// Move item to group without file attachment
promise = waitForItemEvent('add');
req = await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/updateSession",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
target: group.treeViewID
})
}
);
// Old items are gone
let ids3 = await promise;
assert.equal(req.status, 200);
assert.isFalse(Zotero.Items.exists(item2.id));
assert.isFalse(Zotero.Items.exists(item1.id));
// New item exists
let item3 = Zotero.Items.get(ids3[0]);
assert.equal(item3.libraryID, group.libraryID);
assert.equal(item3.numAttachments(), 0);
// Move back to My Library and resave attachment
let ids4, ids5;
promise = waitForItemEvent('add').then(function (ids) {
ids4 = ids;
return waitForItemEvent('add').then(function (ids) {
ids5 = ids;
});
});
req = await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/updateSession",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
target: Zotero.Libraries.userLibrary.treeViewID
})
}
);
await promise;
let item4 = Zotero.Items.get(ids4[0]);
let item5 = Zotero.Items.get(ids5[0]);
// Check item
assert.equal(req.status, 200);
assert.isFalse(Zotero.Items.exists(item3.id));
assert.equal(item4.libraryID, Zotero.Libraries.userLibraryID);
assert.equal(item5.libraryID, Zotero.Libraries.userLibraryID);
assert.equal(item4.numAttachments(), 1);
// Check attachment html file
let attachmentDirectory = Zotero.Attachments.getStorageDirectory(item5).path;
let path = OS.Path.join(attachmentDirectory, 'test.html');
assert.isTrue(await OS.File.exists(path));
let contents = await Zotero.File.getContentsAsync(path);
assert.equal(contents, '<html><head><title>Title</title><body>Body');
});
it("should resave item saved via /saveSnapshot and /saveSingleFile when moved to filesEditable library", async function () {
let group = await createGroup({ editable: true, filesEditable: false });
await selectLibrary(win);
await waitForItemsLoad(win);
let sessionID = Zotero.Utilities.randomString();
// Wait for /saveSnapshot to save parent item
let promise = waitForItemEvent('add');
let title = Zotero.Utilities.randomString();
let payload = {
sessionID,
url: "http://example.com/test",
title,
singleFile: true
};
await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/saveSnapshot",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
}
);
// Check an item exists
let ids1 = await promise;
let item1 = Zotero.Items.get(ids1[0]);
// Move item to group without file attachment
promise = waitForItemEvent('add');
let reqPromise = Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/updateSession",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
target: group.treeViewID
})
}
);
let req = await reqPromise;
assert.equal(req.status, 200);
// Assert original item no longer exists
assert.isFalse(Zotero.Items.exists(item1.id));
// Get new item
let ids2 = await promise;
let item2 = Zotero.Items.get(ids2[0]);
assert.equal(item2.libraryID, group.libraryID);
assert.equal(item2.numAttachments(), 0);
let body = JSON.stringify(Object.assign(payload, {
snapshotContent: '<html><head><title>Title</title><body>Body'
}));
req = await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/saveSingleFile",
{
headers: {
"Content-Type": "application/json"
},
body
}
);
// Check the attachment was not saved
assert.equal(req.status, 200);
assert.equal(item2.numAttachments(), 0);
// Move back to My Library and resave attachment
let ids3, ids4;
promise = waitForItemEvent('add').then(function (ids) {
ids3 = ids;
return waitForItemEvent('add').then(function (ids) {
ids4 = ids;
});
});
req = await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/updateSession",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
target: Zotero.Libraries.userLibrary.treeViewID
})
}
);
// Wait for item add and then attachment add
await promise;
let item3 = Zotero.Items.get(ids3[0]);
let item4 = Zotero.Items.get(ids4[0]);
// Check item
assert.equal(req.status, 200);
assert.isFalse(Zotero.Items.exists(item2.id));
assert.equal(item3.libraryID, Zotero.Libraries.userLibraryID);
assert.equal(item3.numAttachments(), 1);
// Check attachment
assert.equal(item4.libraryID, Zotero.Libraries.userLibraryID);
assert.equal(item4.parentItemID, item3.id);
// Check attachment html file
let attachmentDirectory = Zotero.Attachments.getStorageDirectory(item4).path;
let path = OS.Path.join(attachmentDirectory, 'test.html');
assert.isTrue(await OS.File.exists(path));
let contents = await Zotero.File.getContentsAsync(path);
assert.equal(contents, '<html><head><title>Title</title><body>Body');
});
it("should save item saved via /saveItems and /saveSingleFile to another library", async function () {
let group = await createGroup({ editable: true, filesEditable: false });
await selectLibrary(win);
await waitForItemsLoad(win);
let sessionID = Zotero.Utilities.randomString();
// Wait for /saveItems and /saveSingleFile to items
let ids1, ids2;
let promise = waitForItemEvent('add').then(function (ids) {
ids1 = ids;
return waitForItemEvent('add').then(function (ids) {
ids2 = ids;
});
});
let title = Zotero.Utilities.randomString();
let payload = {
sessionID: sessionID,
items: [
{
itemType: "newspaperArticle",
title: title,
creators: [
{
firstName: "First",
lastName: "Last",
creatorType: "author"
}
],
attachments: [
{
title: "Snapshot",
url: `https://example.com/attachment`,
mimeType: "text/html",
singleFile: true
}
]
}
],
uri: "http://example.com"
};
await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/saveItems",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
}
);
let body = JSON.stringify(Object.assign(payload, {
snapshotContent: '<html><head><title>Title</title><body>Body'
}));
let req = await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/saveSingleFile",
{
headers: {
"Content-Type": "application/json"
},
body
}
);
// Check an item exists
await promise;
assert.equal(req.status, 201);
let item1 = Zotero.Items.get(ids1[0]);
assert.equal(item1.numAttachments(), 1);
// Check attachment item
let item2 = Zotero.Items.get(ids2[0]);
assert.equal(item2.libraryID, Zotero.Libraries.userLibraryID);
assert.equal(item2.parentItemID, item1.id);
// Move item to group without file attachment
promise = waitForItemEvent('add');
req = await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/updateSession",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
target: group.treeViewID
})
}
);
// Old items are gone
let ids3 = await promise;
assert.equal(req.status, 200);
assert.isFalse(Zotero.Items.exists(item2.id));
assert.isFalse(Zotero.Items.exists(item1.id));
// New item exists
let item3 = Zotero.Items.get(ids3[0]);
assert.equal(item3.libraryID, group.libraryID);
assert.equal(item3.numAttachments(), 0);
// Move back to My Library and resave attachment
let ids4, ids5;
promise = waitForItemEvent('add').then(function (ids) {
ids4 = ids;
return waitForItemEvent('add').then(function (ids) {
ids5 = ids;
});
});
req = await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/updateSession",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
target: Zotero.Libraries.userLibrary.treeViewID
})
}
);
await promise;
let item4 = Zotero.Items.get(ids4[0]);
let item5 = Zotero.Items.get(ids5[0]);
// Check item
assert.equal(req.status, 200);
assert.isFalse(Zotero.Items.exists(item3.id));
assert.equal(item4.libraryID, Zotero.Libraries.userLibraryID);
assert.equal(item5.libraryID, Zotero.Libraries.userLibraryID);
assert.equal(item4.numAttachments(), 1);
// Check attachment html file
let attachmentDirectory = Zotero.Attachments.getStorageDirectory(item5).path;
let path = OS.Path.join(attachmentDirectory, 'attachment.html');
assert.isTrue(await OS.File.exists(path));
let contents = await Zotero.File.getContentsAsync(path);
assert.equal(contents, '<html><head><title>Title</title><body>Body');
});
it("should save item saved via /saveItems and /saveSingleFile when moved to filesEditable library", async function () {
let group = await createGroup({ editable: true, filesEditable: false });
await selectLibrary(win);
await waitForItemsLoad(win);
let sessionID = Zotero.Utilities.randomString();
// Wait for /saveItems to save parent item
let promise = waitForItemEvent('add');
let title = Zotero.Utilities.randomString();
let payload = {
sessionID: sessionID,
items: [
{
itemType: "newspaperArticle",
title: title,
creators: [
{
firstName: "First",
lastName: "Last",
creatorType: "author"
}
],
attachments: [
{
title: "Snapshot",
url: `https://example.com/attachment`,
mimeType: "text/html",
singleFile: true
}
]
}
],
uri: "http://example.com"
};
await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/saveItems",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
}
);
// Check an item exists
let ids1 = await promise;
let item1 = Zotero.Items.get(ids1[0]);
// Move item to group without file attachment
promise = waitForItemEvent('add');
let reqPromise = Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/updateSession",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
target: group.treeViewID
})
}
);
let req = await reqPromise;
assert.equal(req.status, 200);
// Assert original item no longer exists
assert.isFalse(Zotero.Items.exists(item1.id));
// Get new item
let ids2 = await promise;
let item2 = Zotero.Items.get(ids2[0]);
assert.equal(item2.libraryID, group.libraryID);
assert.equal(item2.numAttachments(), 0);
let body = JSON.stringify(Object.assign(payload, {
snapshotContent: '<html><head><title>Title</title><body>Body'
}));
req = await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/saveSingleFile",
{
headers: {
"Content-Type": "application/json"
},
body
}
);
// Check the attachment was not saved
assert.equal(req.status, 200);
assert.equal(item2.numAttachments(), 0);
// Move back to My Library and resave attachment
let ids3, ids4;
promise = waitForItemEvent('add').then(function (ids) {
ids3 = ids;
return waitForItemEvent('add').then(function (ids) {
ids4 = ids;
});
});
req = await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/updateSession",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
target: Zotero.Libraries.userLibrary.treeViewID
})
}
);
// Wait for item add and then attachment add
await promise;
let item3 = Zotero.Items.get(ids3[0]);
let item4 = Zotero.Items.get(ids4[0]);
// Check item
assert.equal(req.status, 200);
assert.isFalse(Zotero.Items.exists(item2.id));
assert.equal(item3.libraryID, Zotero.Libraries.userLibraryID);
assert.equal(item3.numAttachments(), 1);
// Check attachment
assert.equal(item4.libraryID, Zotero.Libraries.userLibraryID);
assert.equal(item4.parentItemID, item3.id);
// Check attachment html file
let attachmentDirectory = Zotero.Attachments.getStorageDirectory(item4).path;
let path = OS.Path.join(attachmentDirectory, 'attachment.html');
assert.isTrue(await OS.File.exists(path));
let contents = await Zotero.File.getContentsAsync(path);
assert.equal(contents, '<html><head><title>Title</title><body>Body');
});
});
describe('/connector/installStyle', function() {
var endpoint;
var style;
before(function() {
endpoint = connectorServerPath + "/connector/installStyle";
style = `<?xml version="1.0" encoding="utf-8"?>
<style xmlns="http://purl.org/net/xbiblio/csl" version="1.0" default-locale="de-DE">
<info>
<title>Test1</title>
<id>http://www.example.com/test2</id>
<link href="http://www.zotero.org/styles/cell" rel="independent-parent"/>
</info>
</style>
`;
});
it('should reject styles with invalid text', function* () {
var error = yield getPromiseError(Zotero.HTTP.request(
'POST',
endpoint,
{
headers: { "Content-Type": "application/json" },
body: '{}'
}
));
assert.instanceOf(error, Zotero.HTTP.UnexpectedStatusException);
assert.equal(error.xmlhttp.status, 400);
assert.equal(error.xmlhttp.responseText, Zotero.getString("styles.installError", "(null)"));
});
it('should import a style with application/vnd.citationstyles.style+xml content-type', function* () {
sinon.stub(Zotero.Styles, 'install').callsFake(function(style) {
var parser = new DOMParser(),
doc = parser.parseFromString(style, "application/xml");
return Zotero.Promise.resolve({
styleTitle: Zotero.Utilities.xpathText(
doc, '/csl:style/csl:info[1]/csl:title[1]', Zotero.Styles.ns
),
styleID: Zotero.Utilities.xpathText(
doc, '/csl:style/csl:info[1]/csl:id[1]', Zotero.Styles.ns
)
});
});
var response = yield Zotero.HTTP.request(
'POST',
endpoint,
{
headers: { "Content-Type": "application/vnd.citationstyles.style+xml" },
body: style
}
);
assert.equal(response.status, 201);
assert.equal(response.response, JSON.stringify({name: 'Test1'}));
Zotero.Styles.install.restore();
});
it('should accept text/plain request with X-Zotero-Connector-API-Version or Zotero-Allowed-Request', async function () {
sinon.stub(Zotero.Styles, 'install').callsFake(function(style) {
var parser = new DOMParser(),
doc = parser.parseFromString(style, "application/xml");
return Zotero.Promise.resolve({
styleTitle: Zotero.Utilities.xpathText(
doc, '/csl:style/csl:info[1]/csl:title[1]', Zotero.Styles.ns
),
styleID: Zotero.Utilities.xpathText(
doc, '/csl:style/csl:info[1]/csl:id[1]', Zotero.Styles.ns
)
});
});
// X-Zotero-Connector-API-Version
var response = await Zotero.HTTP.request(
'POST',
endpoint,
{
headers: {
"Content-Type": "text/plain",
"X-Zotero-Connector-API-Version": "2"
},
body: style
}
);
assert.equal(response.status, 201);
// Zotero-Allowed-Request
response = await Zotero.HTTP.request(
'POST',
endpoint,
{
headers: {
"Content-Type": "text/plain",
"Zotero-Allowed-Request": "1"
},
body: style
}
);
assert.equal(response.status, 201);
Zotero.Styles.install.restore();
});
it('should reject text/plain request without X-Zotero-Connector-API-Version', async function () {
var req = await Zotero.HTTP.request(
'POST',
endpoint,
{
headers: {
"Content-Type": "text/plain"
},
body: style,
successCodes: [403]
}
);
assert.equal(req.status, 403);
});
});
describe('/connector/import', function() {
var endpoint;
before(function() {
endpoint = connectorServerPath + "/connector/import";
});
it('should reject resources that do not contain import data', function* () {
var error = yield getPromiseError(Zotero.HTTP.request(
'POST',
endpoint,
{
headers: {
"Content-Type": "text/plain",
"X-Zotero-Connector-API-Version": "2"
},
body: 'Owl'
}
));
assert.instanceOf(error, Zotero.HTTP.UnexpectedStatusException);
assert.equal(error.xmlhttp.status, 400);
});
it('should reject requests without X-Zotero-Connector-API-Version', async function () {
var req = await Zotero.HTTP.request(
'POST',
endpoint,
{
headers: {
"Content-Type": "text/plain"
},
successCodes: [403]
}
);
assert.equal(req.status, 403);
});
it('should import resources (BibTeX) into selected collection', function* () {
var collection = yield createDataObject('collection');
yield waitForItemsLoad(win);
var resource = `@book{test1,
title={Test1},
author={Owl},
year={1000},
publisher={Curly Braces Publishing},
keywords={A, B}
}`;
var addedItemIDsPromise = waitForItemEvent('add');
var req = yield Zotero.HTTP.request(
'POST',
endpoint,
{
headers: {
"Content-Type": "application/x-bibtex",
"X-Zotero-Connector-API-Version": "2"
},
body: resource
}
);
assert.equal(req.status, 201);
assert.equal(JSON.parse(req.responseText)[0].title, 'Test1');
let itemIDs = yield addedItemIDsPromise;
assert.isTrue(collection.hasItem(itemIDs[0]));
var item = Zotero.Items.get(itemIDs[0]);
assert.sameDeepMembers(item.getTags(), [{ tag: 'A', type: 1 }, { tag: 'B', type: 1 }]);
});
it('should switch to My Library if read-only library is selected', function* () {
var group = yield createGroup({
editable: false
});
yield selectLibrary(win, group.libraryID);
yield waitForItemsLoad(win);
var resource = `@book{test1,
title={Test1},
author={Owl},
year={1000},
publisher={Curly Braces Publishing}
}`;
var addedItemIDsPromise = waitForItemEvent('add');
var req = yield Zotero.HTTP.request(
'POST',
endpoint,
{
headers: {
"Content-Type": "application/x-bibtex",
"X-Zotero-Connector-API-Version": "2"
},
body: resource,
successCodes: false
}
);
assert.equal(req.status, 201);
assert.equal(
win.ZoteroPane.collectionsView.getSelectedLibraryID(),
Zotero.Libraries.userLibraryID
);
let itemIDs = yield addedItemIDsPromise;
var item = Zotero.Items.get(itemIDs[0]);
assert.equal(item.libraryID, Zotero.Libraries.userLibraryID);
});
});
describe('/connector/request', function () {
let endpoint;
before(function () {
endpoint = connectorServerPath + '/connector/request';
});
beforeEach(function () {
Zotero.Server.Connector.Request.enableValidation = true;
});
after(function () {
Zotero.Server.Connector.Request.enableValidation = true;
});
it('should reject GET requests', async function () {
let req = await Zotero.HTTP.request(
'GET',
endpoint,
{
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
method: 'GET',
url: 'https://www.example.com/'
}),
successCodes: false
}
);
assert.equal(req.status, 400);
assert.include(req.responseText, 'Endpoint does not support method');
});
it('should not make requests to arbitrary hosts', async function () {
let req = await Zotero.HTTP.request(
'POST',
endpoint,
{
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
method: 'GET',
url: `http://localhost:${Zotero.Prefs.get('httpServer.port')}/`
}),
successCodes: false
}
);
assert.equal(req.status, 400);
assert.include(req.responseText, 'Unsupported URL');
req = await Zotero.HTTP.request(
'POST',
endpoint,
{
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
method: 'GET',
url: `http://www.example.com/`
}),
successCodes: false
}
);
assert.equal(req.status, 400);
assert.include(req.responseText, 'Unsupported URL');
});
it('should reject requests with non-Mozilla/ user agents', async function () {
let req = await Zotero.HTTP.request(
'POST',
endpoint,
{
headers: {
'content-type': 'application/json',
'user-agent': 'BadBrowser/1.0'
},
body: JSON.stringify({
method: 'GET',
url: `https://www.worldcat.org/api/nonexistent`
}),
successCodes: false
}
);
assert.equal(req.status, 400);
assert.include(req.responseText, 'Unsupported User-Agent');
});
it('should allow a request to an allowed host', async function () {
let stub = sinon.stub(Zotero.HTTP, 'request');
// First call: call original
stub.callThrough();
// Second call (call from within /connector/request handler): return the following
stub.onSecondCall().returns({
status: 200,
getAllResponseHeaders: () => '',
response: 'it went through'
});
let req = await Zotero.HTTP.request(
'POST',
endpoint,
{
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
method: 'GET',
url: `https://www.worldcat.org/api/nonexistent`
})
}
);
assert.equal(req.status, 200);
assert.equal(JSON.parse(req.responseText).body, 'it went through');
stub.restore();
});
it('should return response in translator request() format with lowercase headers', async function () {
let testEndpointPath = '/test/header';
httpd.registerPathHandler(
testEndpointPath,
{
handle: function (request, response) {
response.setStatusLine(null, 200, 'OK');
response.setHeader('X-Some-Header', 'Header value');
response.write('body');
}
}
);
Zotero.Server.Connector.Request.enableValidation = false;
let req = await Zotero.HTTP.request(
'POST',
endpoint,
{
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
method: 'GET',
url: testServerPath + testEndpointPath
}),
responseType: 'json'
}
);
assert.equal(req.response.status, 200);
assert.equal(req.response.headers['x-some-header'], 'Header value');
assert.equal(req.response.body, 'body');
});
it('should set Referer', async function () {
let testEndpointPath = '/test/referer';
let referer = 'https://www.example.com/';
httpd.registerPathHandler(
testEndpointPath,
{
handle: function (request, response) {
assert.equal(request.getHeader('Referer'), referer);
response.setStatusLine(null, 200, 'OK');
response.write('');
}
}
);
Zotero.Server.Connector.Request.enableValidation = false;
let req = await Zotero.HTTP.request(
'POST',
endpoint,
{
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
method: 'GET',
url: testServerPath + testEndpointPath,
options: {
headers: {
Referer: referer
}
}
})
}
);
assert.equal(JSON.parse(req.response).status, 200);
});
});
});