mirror of
https://github.com/zotero/zotero.git
synced 2026-08-28 05:25:31 +00:00
Some checks are pending
https://github.com/urschrei/pyzotero/issues/344#issuecomment-5108140508
2310 lines
76 KiB
JavaScript
2310 lines
76 KiB
JavaScript
"use strict";
|
|
|
|
describe("Local API Server", function () {
|
|
let apiRoot;
|
|
let serverID;
|
|
|
|
let collection;
|
|
let subcollection;
|
|
let collectionItem1;
|
|
let collectionItem2;
|
|
let collectionItem3;
|
|
let subcollectionItem;
|
|
let subcollectionAttachment;
|
|
let subcollectionAnnotation;
|
|
let allItems;
|
|
|
|
function apiGet(endpoint, options = {}) {
|
|
return Zotero.HTTP.request('GET', apiRoot + endpoint, {
|
|
headers: {
|
|
'Zotero-Allowed-Request': '1'
|
|
},
|
|
responseType: 'json',
|
|
...options
|
|
});
|
|
}
|
|
|
|
// API key used by write tests; set up in the Write requests before() hook.
|
|
let writeAPIKey;
|
|
|
|
function apiRequest(method, endpoint, options = {}) {
|
|
let { body, headers, skipAPIKey, skipServerID, ...rest } = options;
|
|
let requestHeaders = {
|
|
'Zotero-Allowed-Request': '1',
|
|
...(headers || {})
|
|
};
|
|
if (!skipAPIKey && writeAPIKey
|
|
&& !requestHeaders['Zotero-API-Key']
|
|
&& !endpoint.includes('key=')) {
|
|
requestHeaders['Zotero-API-Key'] = writeAPIKey;
|
|
}
|
|
if (!skipServerID && serverID && !requestHeaders['Zotero-Server-ID']) {
|
|
requestHeaders['Zotero-Server-ID'] = serverID;
|
|
}
|
|
let requestBody = body;
|
|
if (body !== undefined && typeof body !== 'string'
|
|
&& !(body instanceof ArrayBuffer)
|
|
&& !ArrayBuffer.isView(body)) {
|
|
requestBody = JSON.stringify(body);
|
|
if (!requestHeaders['Content-Type']) {
|
|
requestHeaders['Content-Type'] = 'application/json';
|
|
}
|
|
}
|
|
return Zotero.HTTP.request(method, apiRoot + endpoint, {
|
|
headers: requestHeaders,
|
|
body: requestBody,
|
|
responseType: 'json',
|
|
...rest
|
|
});
|
|
}
|
|
|
|
function apiPost(endpoint, options = {}) {
|
|
return apiRequest('POST', endpoint, options);
|
|
}
|
|
|
|
function apiPut(endpoint, options = {}) {
|
|
return apiRequest('PUT', endpoint, options);
|
|
}
|
|
|
|
function apiPatch(endpoint, options = {}) {
|
|
return apiRequest('PATCH', endpoint, options);
|
|
}
|
|
|
|
function apiDelete(endpoint, options = {}) {
|
|
return apiRequest('DELETE', endpoint, options);
|
|
}
|
|
|
|
// Counter so each test generates a fresh write token (server caches them for 12h)
|
|
let writeTokenCounter = 0;
|
|
function newWriteToken() {
|
|
writeTokenCounter++;
|
|
return ('writeToken' + writeTokenCounter + 'x'.repeat(32))
|
|
.substring(0, 32);
|
|
}
|
|
|
|
function authorizePost(body, { responseType = 'json', successCodes } = {}) {
|
|
let options = {
|
|
headers: {
|
|
'Zotero-Allowed-Request': '1',
|
|
'Content-Type': 'application/json',
|
|
'Zotero-Server-ID': serverID,
|
|
},
|
|
body: typeof body === 'string' ? body : JSON.stringify(body),
|
|
responseType
|
|
};
|
|
if (successCodes) {
|
|
options.successCodes = successCodes;
|
|
}
|
|
return Zotero.HTTP.request('POST', apiRoot + '/local/authorize', options);
|
|
}
|
|
|
|
// Stub the dialog and obtain a remembered API key directly via the endpoint
|
|
async function setupRememberedAPIKey(appName = 'Test Suite') {
|
|
let stub = sinon.stub(Zotero.Server.LocalAPI, '_promptForAuthorization')
|
|
.resolves({ allow: true, remember: true });
|
|
try {
|
|
let { response } = await authorizePost({ appName });
|
|
return response.key;
|
|
}
|
|
finally {
|
|
stub.restore();
|
|
}
|
|
}
|
|
|
|
before(async function () {
|
|
apiRoot = 'http://127.0.0.1:' + Zotero.Server.port + '/api';
|
|
|
|
await resetDB({
|
|
thisArg: this
|
|
});
|
|
|
|
// Regenerated in the settings table on reset, so read it back afterward
|
|
serverID = Zotero.Server.LocalAPI.getServerID();
|
|
|
|
collection = await createDataObject('collection', { setTitle: true });
|
|
subcollection = await createDataObject('collection', { setTitle: true, parentID: collection.id });
|
|
collectionItem1 = await createDataObject('item', {
|
|
setTitle: true,
|
|
collections: [collection.id],
|
|
itemType: 'bookSection',
|
|
tags: ['another tag'],
|
|
});
|
|
collectionItem1.setCreators([{ firstName: 'A', lastName: 'Person', creatorType: 'author' }]);
|
|
collectionItem1.saveTx();
|
|
collectionItem2 = await createDataObject('item', {
|
|
setTitle: true,
|
|
collections: [collection.id],
|
|
tags: ['some tag'],
|
|
});
|
|
collectionItem2.setCreators([{ firstName: 'A', lastName: 'Zerson', creatorType: 'author' }]);
|
|
collectionItem2.saveTx();
|
|
collectionItem3 = await createDataObject('item', {
|
|
setTitle: true,
|
|
collections: [collection.id],
|
|
itemType: 'journalArticle',
|
|
tags: ['another tag', 'a third tag'],
|
|
});
|
|
collectionItem3.setCreators([{ firstName: 'B', lastName: 'XYZ', creatorType: 'author' }]);
|
|
collectionItem3.saveTx();
|
|
subcollectionItem = await createDataObject('item', {
|
|
setTitle: true,
|
|
collections: [subcollection.id],
|
|
});
|
|
subcollectionAttachment = await importPDFAttachment(subcollectionItem);
|
|
subcollectionAnnotation = await createAnnotation('highlight', subcollectionAttachment);
|
|
allItems = [collectionItem1, collectionItem2, collectionItem3, subcollectionItem, subcollectionAttachment, subcollectionAnnotation];
|
|
});
|
|
|
|
describe("/", function () {
|
|
it("should return a Zotero-API-Version response header", async function () {
|
|
let xhr = await Zotero.HTTP.request('GET', apiRoot + '/', {
|
|
headers: {
|
|
'Zotero-Allowed-Request': '1'
|
|
}
|
|
});
|
|
assert.equal(xhr.getResponseHeader('Zotero-API-Version'), ZOTERO_CONFIG.API_VERSION);
|
|
});
|
|
|
|
it("should allow an old Zotero-API-Version request header", async function () {
|
|
let xhr = await Zotero.HTTP.request('GET', apiRoot + '/', {
|
|
headers: {
|
|
'Zotero-Allowed-Request': '1',
|
|
'Zotero-API-Version': '2',
|
|
}
|
|
});
|
|
assert.isNotEmpty(xhr.getResponseHeader('Zotero-API-Version'));
|
|
});
|
|
});
|
|
|
|
describe("Zotero-Server-ID", function () {
|
|
it("should return the server ID in a response header", async function () {
|
|
let xhr = await apiGet('/users/0/items');
|
|
assert.equal(xhr.getResponseHeader('Zotero-Server-ID'), serverID);
|
|
});
|
|
|
|
it("should accept a read with a matching server ID", async function () {
|
|
let xhr = await apiGet('/users/0/items', {
|
|
headers: {
|
|
'Zotero-Allowed-Request': '1',
|
|
'Zotero-Server-ID': serverID,
|
|
}
|
|
});
|
|
assert.equal(xhr.status, 200);
|
|
});
|
|
|
|
it("should accept a read with no server ID", async function () {
|
|
// apiGet() doesn't send the header
|
|
let xhr = await apiGet('/users/0/items');
|
|
assert.equal(xhr.status, 200);
|
|
});
|
|
|
|
it("should reject a read with a mismatched server ID with 412", async function () {
|
|
let xhr = await apiGet('/users/0/items', {
|
|
headers: {
|
|
'Zotero-Allowed-Request': '1',
|
|
'Zotero-Server-ID': 'wrongServerID',
|
|
},
|
|
responseType: 'text',
|
|
successCodes: [412]
|
|
});
|
|
assert.equal(xhr.status, 412);
|
|
// The rejection still carries the real server ID, so a client with a
|
|
// stale ID can recover
|
|
assert.equal(xhr.getResponseHeader('Zotero-Server-ID'), serverID);
|
|
});
|
|
|
|
it("should require a server ID on writes with 428", async function () {
|
|
let xhr = await apiPost('/users/0/items', {
|
|
body: [],
|
|
skipServerID: true,
|
|
skipAPIKey: true,
|
|
responseType: 'text',
|
|
successCodes: [428]
|
|
});
|
|
assert.equal(xhr.status, 428);
|
|
});
|
|
|
|
it("should reject a write with a mismatched server ID with 412", async function () {
|
|
let xhr = await apiPost('/users/0/items', {
|
|
body: [],
|
|
headers: { 'Zotero-Server-ID': 'wrongServerID' },
|
|
skipAPIKey: true,
|
|
responseType: 'text',
|
|
successCodes: [412]
|
|
});
|
|
assert.equal(xhr.status, 412);
|
|
});
|
|
|
|
it("should validate the server ID before the API key on writes", async function () {
|
|
// A valid server ID but no API key passes the server-ID check and
|
|
// then fails authentication with 401
|
|
let xhr = await apiPost('/users/0/items', {
|
|
body: [],
|
|
skipAPIKey: true,
|
|
responseType: 'text',
|
|
successCodes: [401]
|
|
});
|
|
assert.equal(xhr.status, 401);
|
|
});
|
|
});
|
|
|
|
describe("<userOrGroupPrefix>/collections", function () {
|
|
it("should return all collections", async function () {
|
|
let { response } = await apiGet('/users/0/collections');
|
|
assert.isArray(response);
|
|
assert.lengthOf(response, 2);
|
|
|
|
let col = response.find(c => c.key == collection.key);
|
|
let subcol = response.find(c => c.key == subcollection.key);
|
|
|
|
assert.equal(col.data.name, collection.name);
|
|
assert.equal(col.meta.numCollections, 1);
|
|
assert.equal(col.meta.numItems, 3);
|
|
|
|
assert.equal(subcol.data.name, subcollection.name);
|
|
assert.equal(subcol.meta.numCollections, 0);
|
|
assert.equal(subcol.meta.numItems, 1);
|
|
});
|
|
|
|
describe("/top", function () {
|
|
it("should return top-level collections", async function () {
|
|
let { response } = await apiGet('/users/0/collections/top');
|
|
assert.isArray(response);
|
|
assert.lengthOf(response, 1);
|
|
|
|
let col = response.find(c => c.key == collection.key);
|
|
assert.ok(col);
|
|
});
|
|
});
|
|
|
|
describe("/<key>", function () {
|
|
it("should return a collection with parent information", async function () {
|
|
let { response } = await apiGet(`/users/0/collections/${subcollection.key}`);
|
|
assert.isNotArray(response);
|
|
assert.equal(response.data.name, subcollection.name);
|
|
assert.equal(response.data.parentCollection, collection.key);
|
|
assert.include(response.links.up.href, collection.key);
|
|
});
|
|
|
|
describe("/items", function () {
|
|
it("should not include annotations", async function () {
|
|
let { response } = await apiGet(`/users/0/collections/${subcollection.key}/items`);
|
|
assert.isArray(response);
|
|
assert.sameMembers(response.map(item => item.key), [subcollectionItem.key, subcollectionAttachment.key]);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("<userOrGroupPrefix>/items", function () {
|
|
it("should return all items", async function () {
|
|
let { response } = await apiGet('/users/0/items');
|
|
assert.isArray(response);
|
|
assert.lengthOf(response, allItems.length);
|
|
});
|
|
|
|
describe("/top", function () {
|
|
it("should return top-level items", async function () {
|
|
let { response } = await apiGet('/users/0/items/top');
|
|
assert.isArray(response);
|
|
assert.sameMembers(response.map(item => item.key), [collectionItem1.key, collectionItem2.key, collectionItem3.key, subcollectionItem.key]);
|
|
});
|
|
});
|
|
|
|
describe("/:itemID/children", function () {
|
|
it("should return the children and not return the parent", async function () {
|
|
let { response } = await apiGet(`/users/0/items/${subcollectionItem.key}/children`);
|
|
assert.lengthOf(response, 1);
|
|
});
|
|
});
|
|
|
|
describe("Child attachment items", function () {
|
|
it("should have 'up' and 'enclosure' links", async function () {
|
|
let { response } = await apiGet(`/users/0/items/${subcollectionAttachment.key}`);
|
|
assert.isTrue(response.links.up.href.includes('/api/'));
|
|
assert.isTrue(response.links.enclosure.href.startsWith('file:'));
|
|
});
|
|
|
|
it("should return file URL from /file/view/url", async function () {
|
|
let { response } = await apiGet(`/users/0/items/${subcollectionAttachment.key}/file/view/url`, { responseType: 'text' });
|
|
assert.isTrue(response.startsWith('file:'));
|
|
});
|
|
|
|
// followRedirects: false not working?
|
|
it.skip("should redirect to file URL from /file/view", async function () {
|
|
let request = await apiGet(`/users/0/items/${subcollectionAttachment.key}/file/view`,
|
|
{ responseType: 'text', followRedirects: false });
|
|
assert.isTrue(request.getResponseHeader('Location').startsWith('file:'));
|
|
});
|
|
|
|
it("should return full-text data from /fulltext", async function () {
|
|
let { response } = await apiGet(`/users/0/items/${subcollectionAttachment.key}/fulltext`);
|
|
assert.deepEqual(response, {
|
|
content: 'Zotero [zoh-TAIR-oh] is a free, easy-to-use tool to help you collect, organize, cite, and share your research sources.',
|
|
indexedPages: 1,
|
|
totalPages: 1,
|
|
});
|
|
});
|
|
|
|
it("should return a 404 from /fulltext when attachment has no full-text content", async function () {
|
|
let tempAttachment = await importFileAttachment('empty.pdf');
|
|
let { response } = await apiGet(`/users/0/items/${tempAttachment.key}/fulltext`, {
|
|
successCodes: [404],
|
|
responseType: 'text'
|
|
});
|
|
assert.equal(response, 'Not found');
|
|
await tempAttachment.eraseTx();
|
|
});
|
|
});
|
|
|
|
describe("?itemType", function () {
|
|
it("should filter by item type", async function () {
|
|
let { response } = await apiGet('/users/0/items?itemType=book');
|
|
assert.lengthOf(response, 2);
|
|
assert.isTrue(response.every(item => item.data.itemType == 'book'));
|
|
});
|
|
|
|
it("should match annotations", async function () {
|
|
let { response } = await apiGet('/users/0/items?itemType=annotation');
|
|
assert.lengthOf(response, 1);
|
|
assert.equal(response[0].key, subcollectionAnnotation.key);
|
|
assert.equal(response[0].data.annotationComment, subcollectionAnnotation.annotationComment);
|
|
});
|
|
|
|
it("should be able to be negated", async function () {
|
|
let { response } = await apiGet('/users/0/items?itemType=-book');
|
|
assert.lengthOf(response, 3);
|
|
assert.isTrue(response.every(item => item.data.itemType != 'book'));
|
|
});
|
|
|
|
it("should support OR combinations", async function () {
|
|
let { response } = await apiGet('/users/0/items?itemType=book || bookSection');
|
|
assert.lengthOf(response, 3);
|
|
});
|
|
});
|
|
|
|
describe("?tag", function () {
|
|
it("should filter by tag", async function () {
|
|
let { response } = await apiGet('/users/0/items?tag=some tag');
|
|
assert.lengthOf(response, 1);
|
|
});
|
|
|
|
it("should be able to be negated", async function () {
|
|
let { response } = await apiGet('/users/0/items?tag=-some tag');
|
|
assert.lengthOf(response, 4);
|
|
});
|
|
|
|
it("should be able to be combined with ?itemType", async function () {
|
|
let { response } = await apiGet('/users/0/items?itemType=book&tag=some tag');
|
|
assert.lengthOf(response, 1);
|
|
});
|
|
|
|
it("should support OR combinations", async function () {
|
|
let { response } = await apiGet('/users/0/items?tag=some tag || another tag');
|
|
assert.lengthOf(response, 3);
|
|
});
|
|
|
|
it("should support OR and NOT combinations", async function () {
|
|
let { response } = await apiGet('/users/0/items?tag=some tag || another tag&tag=-a third tag');
|
|
assert.lengthOf(response, 2);
|
|
});
|
|
});
|
|
|
|
describe("?format", function () {
|
|
describe("=ris", function () {
|
|
it("should output RIS", async function () {
|
|
let { response } = await apiGet('/users/0/items?format=ris', { responseType: 'text' });
|
|
assert.isTrue(response.startsWith('TY'));
|
|
});
|
|
});
|
|
|
|
describe("=bib", function () {
|
|
it("should output a bibliography", async function () {
|
|
let { response } = await apiGet('/users/0/items?format=bib', { responseType: 'text' });
|
|
assert.isTrue(response.startsWith('<div class="csl-bib-body"'));
|
|
});
|
|
});
|
|
|
|
describe("=keys", function () {
|
|
it("should output a plain-text list of keys", async function () {
|
|
let { response } = await apiGet('/users/0/items?format=keys', { responseType: 'text' });
|
|
for (let item of allItems) {
|
|
assert.isTrue(response.includes(item.key));
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("=versions", function () {
|
|
it("should output a JSON object mapping keys to local versions", async function () {
|
|
let { response } = await apiGet('/users/0/items?format=versions');
|
|
assert.isAbove(collectionItem1.clientVersion, 0);
|
|
assert.propertyVal(response, collectionItem1.key, collectionItem1.clientVersion);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("?include", function () {
|
|
it("should exclude data when empty", async function () {
|
|
let { response } = await apiGet(`/users/0/items/${collectionItem1.key}?include=`);
|
|
assert.notProperty(response, 'data');
|
|
});
|
|
|
|
describe("=citation", function () {
|
|
it("should output citations", async function () {
|
|
let { response } = await apiGet('/users/0/items?include=citation');
|
|
assert.isTrue(response[0].citation.startsWith('<ol>'));
|
|
});
|
|
|
|
it("should not accumulate citations on subsequent calls", async function () {
|
|
let { response: r1 } = await apiGet('/users/0/items?include=citation');
|
|
// Be sure generating a bibliography in the middle *also* doesn't affect the state
|
|
await apiGet('/users/0/items?include=bib');
|
|
let { response: r2 } = await apiGet('/users/0/items?include=citation');
|
|
assert.equal(r1[0].citation, r2[0].citation);
|
|
});
|
|
|
|
describe("&style", function () {
|
|
for (let [styleID, type] of [['cell', 'name'], ['https://www.zotero.org/styles/cell', 'URL']]) {
|
|
it(`should install the given citation style by ${type} if not yet installed`, async function () {
|
|
if (Zotero.Styles.get(styleID)) {
|
|
await Zotero.Styles.get(styleID).remove();
|
|
}
|
|
|
|
let styleString = await Zotero.File.getContentsAsync(
|
|
Zotero.File.pathToFile(OS.Path.join(getTestDataDirectory().path, 'cell.csl')));
|
|
|
|
let stub = sinon.stub(Zotero.Styles, 'install');
|
|
stub.callsFake(() => stub.wrappedMethod({ string: styleString }, 'cell.csl', true));
|
|
|
|
let { response } = await apiGet(`/users/0/items/${collectionItem1.key}?include=citation&style=${encodeURIComponent(styleID)}`);
|
|
assert.isTrue(stub.called);
|
|
assert.equal(response.citation, '(Person)');
|
|
|
|
stub.restore();
|
|
});
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("?since", function () {
|
|
it("should filter the results", async function () {
|
|
let version = Zotero.Libraries.userLibrary.clientVersion;
|
|
|
|
let { response: response1 } = await apiGet('/users/0/items?since=' + version);
|
|
assert.isEmpty(response1);
|
|
|
|
let { response: response2 } = await apiGet('/users/0/items?since=0');
|
|
assert.lengthOf(response2, allItems.length);
|
|
|
|
let tempItem = await createDataObject('item');
|
|
let { response: response3 } = await apiGet('/users/0/items?since=' + version);
|
|
assert.lengthOf(response3, 1);
|
|
assert.equal(response3[0].key, tempItem.key);
|
|
assert.equal(response3[0].version, tempItem.clientVersion);
|
|
assert.equal(tempItem.clientVersion, version + 1);
|
|
|
|
await tempItem.eraseTx();
|
|
|
|
let { response: response4 } = await apiGet('/users/0/items?since=' + version);
|
|
assert.lengthOf(response4, 0);
|
|
});
|
|
});
|
|
|
|
describe("?q", function () {
|
|
it("should filter the results", async function () {
|
|
let { response } = await apiGet('/users/0/items?q=Person');
|
|
assert.lengthOf(response, 1);
|
|
});
|
|
});
|
|
|
|
describe("?sort", function () {
|
|
it("should sort by creator", async function () {
|
|
let { response } = await apiGet('/users/0/items?sort=creator&direction=desc');
|
|
assert.isBelow(response.findIndex(item => item.key == collectionItem2.key), response.findIndex(item => item.key == collectionItem1.key));
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("<userOrGroupPrefix>/tags", function () {
|
|
it("should return all tags in the library", async function () {
|
|
let { response } = await apiGet('/users/0/tags');
|
|
assert.lengthOf(response, 3);
|
|
assert.sameMembers(response.map(tag => tag.tag), ['some tag', 'another tag', 'a third tag']);
|
|
assert.equal(response.find(tag => tag.tag === 'some tag').meta.numItems, 1);
|
|
});
|
|
});
|
|
|
|
describe("<userOrGroupPrefix>/fulltext?since=", function () {
|
|
it("should return items with full-text content", async function () {
|
|
let { response } = await apiGet('/users/0/fulltext?since=0');
|
|
assert.deepEqual(Object.entries(response), [[subcollectionAttachment.key, subcollectionAttachment.version]]);
|
|
});
|
|
});
|
|
|
|
describe("/users/<userID>/groups", function () {
|
|
it("should omit Last-Modified-Version", async function () {
|
|
let group = await getGroup();
|
|
let xhr = await apiGet('/users/0/groups');
|
|
assert.isNull(xhr.getResponseHeader('Last-Modified-Version'));
|
|
assert.include(xhr.response.map(g => g.id), group.id);
|
|
});
|
|
|
|
it("should ignore ?since", async function () {
|
|
let group = await getGroup();
|
|
let { response: all } = await apiGet('/users/0/groups');
|
|
let { response: since } = await apiGet(`/users/0/groups?since=${group.version + 1}`);
|
|
assert.sameDeepMembers(since, all);
|
|
});
|
|
|
|
it("should map group IDs to metadata versions with ?format=versions", async function () {
|
|
let group = await getGroup();
|
|
let { response } = await apiGet('/users/0/groups?format=versions');
|
|
assert.propertyVal(response, String(group.id), group.version);
|
|
});
|
|
});
|
|
|
|
describe("/groups/<groupID>", function () {
|
|
it("should report the synced group version at the top level, in data, and in Last-Modified-Version", async function () {
|
|
let group = await getGroup();
|
|
// Changing the group library's contents doesn't affect the group metadata version
|
|
let item = await createDataObject('item', { libraryID: group.libraryID });
|
|
let xhr = await apiGet(`/groups/${group.groupID}`);
|
|
let response = xhr.response;
|
|
assert.notEqual(group.version, group.clientVersion);
|
|
assert.equal(response.version, group.version);
|
|
assert.equal(response.data.version, group.version);
|
|
assert.equal(
|
|
parseInt(xhr.getResponseHeader('Last-Modified-Version')),
|
|
group.version
|
|
);
|
|
await item.eraseTx();
|
|
});
|
|
|
|
it("should return 404 for unknown group", async function () {
|
|
let { response } = await apiGet(
|
|
'/groups/99999999999',
|
|
{
|
|
successCodes: [404],
|
|
responseType: 'text'
|
|
}
|
|
);
|
|
assert.equal(response, "Not found");
|
|
});
|
|
});
|
|
|
|
// ===================================================================
|
|
// Write request tests
|
|
// ===================================================================
|
|
|
|
describe("Write requests", function () {
|
|
before(async function () {
|
|
Zotero.Server.LocalAPI._resetAuthorizeRateLimit();
|
|
writeAPIKey = await setupRememberedAPIKey();
|
|
});
|
|
|
|
// Reset the local/authorize rate limit between tests so a long sequence of
|
|
// stubbed prompts can run without tripping the per-minute 429 throttle.
|
|
beforeEach(function () {
|
|
Zotero.Server.LocalAPI._resetAuthorizeRateLimit();
|
|
});
|
|
|
|
describe("POST /api/local/authorize", function () {
|
|
it("should return a key on allow", async function () {
|
|
let stub = sinon.stub(Zotero.Server.LocalAPI, '_promptForAuthorization')
|
|
.resolves({ allow: true, remember: false });
|
|
try {
|
|
let { response, status } = await authorizePost({ appName: 'AllowApp' });
|
|
assert.equal(status, 200);
|
|
assert.isString(response.key);
|
|
assert.lengthOf(response.key, 32);
|
|
assert.isFalse(response.remember);
|
|
}
|
|
finally {
|
|
stub.restore();
|
|
}
|
|
});
|
|
|
|
it("should mark a key as remembered when Always Allow is chosen", async function () {
|
|
let stub = sinon.stub(Zotero.Server.LocalAPI, '_promptForAuthorization')
|
|
.resolves({ allow: true, remember: true });
|
|
try {
|
|
let { response } = await authorizePost({ appName: 'RememberApp' });
|
|
assert.isTrue(response.remember);
|
|
}
|
|
finally {
|
|
stub.restore();
|
|
}
|
|
});
|
|
|
|
it("should return 403 on deny", async function () {
|
|
let stub = sinon.stub(Zotero.Server.LocalAPI, '_promptForAuthorization')
|
|
.resolves({ allow: false, remember: false });
|
|
try {
|
|
let { response, status } = await authorizePost(
|
|
{ appName: 'DeniedApp' }, { successCodes: [403] });
|
|
assert.equal(status, 403);
|
|
assert.isTrue(response.denied);
|
|
}
|
|
finally {
|
|
stub.restore();
|
|
}
|
|
});
|
|
|
|
it("should require appName", async function () {
|
|
let { status } = await authorizePost(
|
|
{}, { responseType: 'text', successCodes: [400] });
|
|
assert.equal(status, 400);
|
|
});
|
|
|
|
it("should pass appName into the prompt", async function () {
|
|
let captured;
|
|
let stub = sinon.stub(Zotero.Server.LocalAPI, '_promptForAuthorization')
|
|
.callsFake(async (appName) => {
|
|
captured = appName;
|
|
return { allow: false };
|
|
});
|
|
try {
|
|
await authorizePost(
|
|
{ appName: 'MyTestApp 1.0' }, { responseType: 'text', successCodes: [403] });
|
|
}
|
|
finally {
|
|
stub.restore();
|
|
}
|
|
assert.equal(captured, 'MyTestApp 1.0');
|
|
});
|
|
|
|
it("should rate-limit after 5 confirmation-requiring requests in a minute", async function () {
|
|
let stub = sinon.stub(Zotero.Server.LocalAPI, '_promptForAuthorization')
|
|
.resolves({ allow: false, remember: false });
|
|
try {
|
|
for (let i = 0; i < 5; i++) {
|
|
await authorizePost(
|
|
{ appName: 'Throttle ' + i }, { responseType: 'text', successCodes: [403] });
|
|
}
|
|
let xhr = await authorizePost(
|
|
{ appName: 'Throttle 6' }, { responseType: 'text', successCodes: [429] });
|
|
assert.equal(xhr.status, 429);
|
|
let retryAfter = parseInt(xhr.getResponseHeader('Retry-After'));
|
|
assert.isAbove(retryAfter, 0);
|
|
assert.isAtMost(retryAfter, 60);
|
|
}
|
|
finally {
|
|
stub.restore();
|
|
}
|
|
});
|
|
|
|
it("should not count rejected (pre-prompt) requests against the rate limit", async function () {
|
|
// 5 bad-appName requests fail before the prompt and don't consume slots
|
|
for (let i = 0; i < 5; i++) {
|
|
await authorizePost(
|
|
{}, { responseType: 'text', successCodes: [400] });
|
|
}
|
|
// A subsequent valid request still goes through
|
|
let stub = sinon.stub(Zotero.Server.LocalAPI, '_promptForAuthorization')
|
|
.resolves({ allow: false, remember: false });
|
|
try {
|
|
let { status } = await authorizePost(
|
|
{ appName: 'AfterBadReqs' }, { successCodes: [403] });
|
|
assert.equal(status, 403);
|
|
}
|
|
finally {
|
|
stub.restore();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Authorization management", function () {
|
|
it("should report a count of remembered authorizations", async function () {
|
|
let before = await Zotero.Server.LocalAPI.getAuthorizationCount();
|
|
await setupRememberedAPIKey('CountedApp');
|
|
let after = await Zotero.Server.LocalAPI.getAuthorizationCount();
|
|
assert.equal(after, before + 1);
|
|
});
|
|
|
|
it("should drop every stored key when cleared", async function () {
|
|
await setupRememberedAPIKey('ToClearApp');
|
|
assert.isAbove(await Zotero.Server.LocalAPI.getAuthorizationCount(), 0);
|
|
await Zotero.Server.LocalAPI.clearAuthorizations();
|
|
assert.equal(await Zotero.Server.LocalAPI.getAuthorizationCount(), 0);
|
|
|
|
// Previously-issued keys must no longer authenticate writes
|
|
let { status } = await apiPost('/users/0/items', {
|
|
body: [{ itemType: 'book' }],
|
|
headers: {
|
|
'Zotero-Write-Token': newWriteToken(),
|
|
'Zotero-API-Key': writeAPIKey,
|
|
},
|
|
skipAPIKey: true,
|
|
successCodes: [401],
|
|
responseType: 'text'
|
|
});
|
|
assert.equal(status, 401);
|
|
|
|
// Restore the suite-wide key so later tests still work
|
|
writeAPIKey = await setupRememberedAPIKey('Test Suite');
|
|
});
|
|
});
|
|
|
|
describe("Authentication", function () {
|
|
it("should reject write requests with no API key (401)", async function () {
|
|
let { status } = await apiPost('/users/0/items', {
|
|
body: [{ itemType: 'book' }],
|
|
headers: { 'Zotero-Write-Token': newWriteToken() },
|
|
skipAPIKey: true,
|
|
successCodes: [401],
|
|
responseType: 'text'
|
|
});
|
|
assert.equal(status, 401);
|
|
});
|
|
|
|
it("should reject write requests with an unknown API key (401)", async function () {
|
|
let { status } = await apiPost('/users/0/items', {
|
|
body: [{ itemType: 'book' }],
|
|
headers: {
|
|
'Zotero-Write-Token': newWriteToken(),
|
|
'Zotero-API-Key': 'not-a-real-key-at-all',
|
|
},
|
|
skipAPIKey: true,
|
|
successCodes: [401],
|
|
responseType: 'text'
|
|
});
|
|
assert.equal(status, 401);
|
|
});
|
|
|
|
it("should accept the API key via ?key= query parameter", async function () {
|
|
let { status, response } = await apiPost(
|
|
`/users/0/items?key=${encodeURIComponent(writeAPIKey)}`,
|
|
{
|
|
body: [{ itemType: 'book', title: 'QueryKey Item' }],
|
|
headers: { 'Zotero-Write-Token': newWriteToken() },
|
|
skipAPIKey: true,
|
|
}
|
|
);
|
|
assert.equal(status, 200);
|
|
let key = response.successful['0'].key;
|
|
let item = await Zotero.Items.getByLibraryAndKeyAsync(
|
|
Zotero.Libraries.userLibraryID, key);
|
|
if (item) await item.eraseTx();
|
|
});
|
|
|
|
it("should accept the API key via Authorization: Bearer header", async function () {
|
|
let { status, response } = await apiPost(
|
|
`/users/0/items`,
|
|
{
|
|
body: [{ itemType: 'book', title: 'BearerKey Item' }],
|
|
headers: {
|
|
'Zotero-Write-Token': newWriteToken(),
|
|
'Authorization': `Bearer ${writeAPIKey}`,
|
|
},
|
|
skipAPIKey: true,
|
|
}
|
|
);
|
|
assert.equal(status, 200);
|
|
let key = response.successful['0'].key;
|
|
let item = await Zotero.Items.getByLibraryAndKeyAsync(
|
|
Zotero.Libraries.userLibraryID, key);
|
|
if (item) await item.eraseTx();
|
|
});
|
|
|
|
it("should consume a single-use API key after one write", async function () {
|
|
let oneShot;
|
|
let stub = sinon.stub(Zotero.Server.LocalAPI, '_promptForAuthorization')
|
|
.resolves({ allow: true, remember: false });
|
|
try {
|
|
let { response } = await authorizePost({ appName: 'OneShotApp' });
|
|
oneShot = response.key;
|
|
}
|
|
finally {
|
|
stub.restore();
|
|
}
|
|
|
|
let { status, response } = await apiPost('/users/0/items', {
|
|
body: [{ itemType: 'book' }],
|
|
headers: {
|
|
'Zotero-Write-Token': newWriteToken(),
|
|
'Zotero-API-Key': oneShot,
|
|
},
|
|
skipAPIKey: true,
|
|
});
|
|
assert.equal(status, 200);
|
|
let key = response.successful['0'].key;
|
|
let item = await Zotero.Items.getByLibraryAndKeyAsync(
|
|
Zotero.Libraries.userLibraryID, key);
|
|
if (item) await item.eraseTx();
|
|
|
|
// Second use must be rejected
|
|
let { status: status2 } = await apiPost('/users/0/items', {
|
|
body: [{ itemType: 'book' }],
|
|
headers: {
|
|
'Zotero-Write-Token': newWriteToken(),
|
|
'Zotero-API-Key': oneShot,
|
|
},
|
|
skipAPIKey: true,
|
|
successCodes: [401],
|
|
responseType: 'text'
|
|
});
|
|
assert.equal(status2, 401);
|
|
});
|
|
|
|
it("should allow remembered keys to be reused", async function () {
|
|
// Use the suite key twice -- it's marked remember:true
|
|
let first = await apiPost('/users/0/items', {
|
|
body: [{ itemType: 'book' }],
|
|
headers: { 'Zotero-Write-Token': newWriteToken() }
|
|
});
|
|
let second = await apiPost('/users/0/items', {
|
|
body: [{ itemType: 'book' }],
|
|
headers: { 'Zotero-Write-Token': newWriteToken() }
|
|
});
|
|
assert.equal(first.status, 200);
|
|
assert.equal(second.status, 200);
|
|
for (let resp of [first, second]) {
|
|
let key = resp.response.successful['0'].key;
|
|
let item = await Zotero.Items.getByLibraryAndKeyAsync(
|
|
Zotero.Libraries.userLibraryID, key);
|
|
if (item) await item.eraseTx();
|
|
}
|
|
});
|
|
|
|
it("should not require API key for GET requests", async function () {
|
|
let { status } = await apiGet('/users/0/items');
|
|
assert.equal(status, 200);
|
|
});
|
|
|
|
it("should persist remembered keys to <profileDir>/localAPIKeys.json", async function () {
|
|
let path = PathUtils.join(Zotero.Profile.dir, 'localAPIKeys.json');
|
|
assert.isTrue(await IOUtils.exists(path));
|
|
let text = await IOUtils.readUTF8(path);
|
|
let json = JSON.parse(text);
|
|
assert.isArray(json.keys);
|
|
assert.isTrue(json.keys.some(k => k.key === writeAPIKey && k.remember));
|
|
});
|
|
});
|
|
|
|
describe("POST <userOrGroupPrefix>/items", function () {
|
|
it("should create a single item", async function () {
|
|
let { response } = await apiPost('/users/0/items', {
|
|
body: [{
|
|
itemType: 'book',
|
|
title: 'New Book From API',
|
|
}],
|
|
headers: { 'Zotero-Write-Token': newWriteToken() }
|
|
});
|
|
assert.isObject(response);
|
|
assert.isObject(response.successful);
|
|
assert.isObject(response.success);
|
|
assert.isObject(response.unchanged);
|
|
assert.isObject(response.failed);
|
|
assert.isEmpty(Object.keys(response.failed));
|
|
assert.lengthOf(Object.keys(response.successful), 1);
|
|
let entry = response.successful['0'];
|
|
assert.equal(entry.data.itemType, 'book');
|
|
assert.equal(entry.data.title, 'New Book From API');
|
|
assert.match(entry.key, /^[23456789ABCDEFGHIJKLMNPQRSTUVWXYZ]{8}$/);
|
|
assert.equal(response.success['0'], entry.key);
|
|
// Top-level version and data.version report the same (local) version
|
|
assert.equal(entry.version, entry.data.version);
|
|
// Cleanup
|
|
let item = await Zotero.Items.getByLibraryAndKeyAsync(
|
|
Zotero.Libraries.userLibraryID, entry.key);
|
|
if (item) await item.eraseTx();
|
|
});
|
|
|
|
it("should create multiple items in one request", async function () {
|
|
let { response } = await apiPost('/users/0/items', {
|
|
body: [
|
|
{ itemType: 'book', title: 'Book A' },
|
|
{ itemType: 'journalArticle', title: 'Article B' }
|
|
],
|
|
headers: { 'Zotero-Write-Token': newWriteToken() }
|
|
});
|
|
assert.lengthOf(Object.keys(response.successful), 2);
|
|
assert.equal(response.successful['0'].data.title, 'Book A');
|
|
assert.equal(response.successful['1'].data.itemType, 'journalArticle');
|
|
// Cleanup
|
|
for (let i of ['0', '1']) {
|
|
let key = response.successful[i].key;
|
|
let item = await Zotero.Items.getByLibraryAndKeyAsync(
|
|
Zotero.Libraries.userLibraryID, key);
|
|
if (item) await item.eraseTx();
|
|
}
|
|
});
|
|
|
|
it("should report failed items in 'failed'", async function () {
|
|
let { response } = await apiPost('/users/0/items', {
|
|
body: [
|
|
{ itemType: 'book', title: 'Valid Book' },
|
|
{ itemType: 'noSuchType', title: 'Invalid' },
|
|
],
|
|
headers: { 'Zotero-Write-Token': newWriteToken() }
|
|
});
|
|
assert.lengthOf(Object.keys(response.successful), 1);
|
|
assert.lengthOf(Object.keys(response.failed), 1);
|
|
let failure = response.failed['1'];
|
|
assert.equal(failure.code, 400);
|
|
assert.include(failure.message.toLowerCase(), 'itemtype');
|
|
// Cleanup
|
|
let key = response.successful['0'].key;
|
|
let item = await Zotero.Items.getByLibraryAndKeyAsync(
|
|
Zotero.Libraries.userLibraryID, key);
|
|
if (item) await item.eraseTx();
|
|
});
|
|
|
|
it("should reject non-array body", async function () {
|
|
let { status } = await apiPost('/users/0/items', {
|
|
body: { itemType: 'book' },
|
|
headers: { 'Zotero-Write-Token': newWriteToken() },
|
|
successCodes: [400],
|
|
responseType: 'text'
|
|
});
|
|
assert.equal(status, 400);
|
|
});
|
|
|
|
it("should reject batches over 50 items with 413", async function () {
|
|
let body = [];
|
|
for (let i = 0; i < 51; i++) {
|
|
body.push({ itemType: 'book', title: 'Book ' + i });
|
|
}
|
|
let { status } = await apiPost('/users/0/items', {
|
|
body,
|
|
headers: { 'Zotero-Write-Token': newWriteToken() },
|
|
successCodes: [413],
|
|
responseType: 'text'
|
|
});
|
|
assert.equal(status, 413);
|
|
});
|
|
|
|
it("should reject duplicate write tokens with 412", async function () {
|
|
let token = newWriteToken();
|
|
await apiPost('/users/0/items', {
|
|
body: [{ itemType: 'book', title: 'First with token' }],
|
|
headers: { 'Zotero-Write-Token': token }
|
|
});
|
|
let { status } = await apiPost('/users/0/items', {
|
|
body: [{ itemType: 'book', title: 'Second with token' }],
|
|
headers: { 'Zotero-Write-Token': token },
|
|
successCodes: [412],
|
|
responseType: 'text'
|
|
});
|
|
assert.equal(status, 412);
|
|
});
|
|
|
|
it("should accept write tokens of 5-32 chars", async function () {
|
|
let { status } = await apiPost('/users/0/items', {
|
|
body: [{ itemType: 'book', title: 'Short token' }],
|
|
headers: { 'Zotero-Write-Token': 'short' }
|
|
});
|
|
assert.equal(status, 200);
|
|
});
|
|
|
|
it("should reject write tokens shorter than 5 chars with 400", async function () {
|
|
let { status } = await apiPost('/users/0/items', {
|
|
body: [{ itemType: 'book' }],
|
|
headers: { 'Zotero-Write-Token': 'abcd' },
|
|
successCodes: [400],
|
|
responseType: 'text'
|
|
});
|
|
assert.equal(status, 400);
|
|
});
|
|
|
|
it("should update an existing item with key+version", async function () {
|
|
let item = await createDataObject('item', { itemType: 'book', setTitle: true });
|
|
let { response } = await apiPost('/users/0/items', {
|
|
body: [{
|
|
key: item.key,
|
|
version: item.clientVersion,
|
|
itemType: 'book',
|
|
title: 'Updated Title',
|
|
}],
|
|
headers: { 'Zotero-Write-Token': newWriteToken() }
|
|
});
|
|
assert.lengthOf(Object.keys(response.successful), 1);
|
|
assert.equal(response.successful['0'].data.title, 'Updated Title');
|
|
let reloaded = await Zotero.Items.getByLibraryAndKeyAsync(
|
|
Zotero.Libraries.userLibraryID, item.key);
|
|
assert.equal(reloaded.getField('title'), 'Updated Title');
|
|
await item.eraseTx();
|
|
});
|
|
|
|
it("should fail an object with stale version", async function () {
|
|
let item = await createDataObject('item', { itemType: 'book', setTitle: true });
|
|
let { response } = await apiPost('/users/0/items', {
|
|
body: [{
|
|
key: item.key,
|
|
version: item.clientVersion - 5,
|
|
itemType: 'book',
|
|
title: 'Stale Update',
|
|
}],
|
|
headers: { 'Zotero-Write-Token': newWriteToken() }
|
|
});
|
|
assert.lengthOf(Object.keys(response.failed), 1);
|
|
assert.equal(response.failed['0'].code, 412);
|
|
await item.eraseTx();
|
|
});
|
|
|
|
it("should fail a keyed object without version or If-Unmodified-Since-Version with 428", async function () {
|
|
let item = await createDataObject('item', { itemType: 'book', setTitle: true });
|
|
let title = item.getField('title');
|
|
let { response } = await apiPost('/users/0/items', {
|
|
body: [{
|
|
key: item.key,
|
|
itemType: 'book',
|
|
title: 'No Precondition',
|
|
}],
|
|
headers: { 'Zotero-Write-Token': newWriteToken() }
|
|
});
|
|
assert.lengthOf(Object.keys(response.failed), 1);
|
|
assert.equal(response.failed['0'].code, 428);
|
|
let reloaded = await Zotero.Items.getByLibraryAndKeyAsync(
|
|
Zotero.Libraries.userLibraryID, item.key);
|
|
assert.equal(reloaded.getField('title'), title);
|
|
await item.eraseTx();
|
|
});
|
|
|
|
it("should allow a keyed object without version when If-Unmodified-Since-Version is provided", async function () {
|
|
let item = await createDataObject('item', { itemType: 'book', setTitle: true });
|
|
let { response } = await apiPost('/users/0/items', {
|
|
body: [{
|
|
key: item.key,
|
|
itemType: 'book',
|
|
title: 'Updated Via Header',
|
|
}],
|
|
headers: {
|
|
'Zotero-Write-Token': newWriteToken(),
|
|
'If-Unmodified-Since-Version':
|
|
String(Zotero.Libraries.userLibrary.clientVersion),
|
|
}
|
|
});
|
|
assert.lengthOf(Object.keys(response.successful), 1);
|
|
let reloaded = await Zotero.Items.getByLibraryAndKeyAsync(
|
|
Zotero.Libraries.userLibraryID, item.key);
|
|
assert.equal(reloaded.getField('title'), 'Updated Via Header');
|
|
await item.eraseTx();
|
|
});
|
|
|
|
it("should return 412 on stale If-Unmodified-Since-Version", async function () {
|
|
let staleVersion = Zotero.Libraries.userLibrary.clientVersion;
|
|
// Bump the library by creating a sentinel item
|
|
let sentinel = await createDataObject('item');
|
|
let { status } = await apiPost('/users/0/items', {
|
|
body: [{ itemType: 'book' }],
|
|
headers: {
|
|
'Zotero-Write-Token': newWriteToken(),
|
|
'If-Unmodified-Since-Version': String(staleVersion)
|
|
},
|
|
successCodes: [412],
|
|
responseType: 'text'
|
|
});
|
|
assert.equal(status, 412);
|
|
await sentinel.eraseTx();
|
|
});
|
|
|
|
it("should include Last-Modified-Version response header", async function () {
|
|
let xhr = await apiPost('/users/0/items', {
|
|
body: [{ itemType: 'book' }],
|
|
headers: { 'Zotero-Write-Token': newWriteToken() }
|
|
});
|
|
let header = xhr.getResponseHeader('Last-Modified-Version');
|
|
assert.isNotEmpty(header);
|
|
assert.equal(
|
|
parseInt(header),
|
|
Zotero.Libraries.userLibrary.clientVersion);
|
|
let key = xhr.response.successful['0'].key;
|
|
let item = await Zotero.Items.getByLibraryAndKeyAsync(
|
|
Zotero.Libraries.userLibraryID, key);
|
|
if (item) await item.eraseTx();
|
|
});
|
|
});
|
|
|
|
describe("PUT <userOrGroupPrefix>/items/<key>", function () {
|
|
it("should fully replace an item's fields", async function () {
|
|
let item = await createDataObject('item', {
|
|
itemType: 'book',
|
|
setTitle: true,
|
|
});
|
|
item.setField('publisher', 'Original Pub');
|
|
await item.saveTx();
|
|
|
|
let { status } = await apiPut(`/users/0/items/${item.key}`, {
|
|
body: {
|
|
key: item.key,
|
|
version: item.clientVersion,
|
|
itemType: 'book',
|
|
title: 'New Title After PUT',
|
|
},
|
|
successCodes: [204]
|
|
});
|
|
assert.equal(status, 204);
|
|
|
|
let reloaded = await Zotero.Items.getByLibraryAndKeyAsync(
|
|
Zotero.Libraries.userLibraryID, item.key);
|
|
assert.equal(reloaded.getField('title'), 'New Title After PUT');
|
|
// PUT should clear unspecified fields
|
|
assert.equal(reloaded.getField('publisher'), '');
|
|
await item.eraseTx();
|
|
});
|
|
|
|
it("should reject without version or If-Unmodified-Since-Version", async function () {
|
|
let item = await createDataObject('item');
|
|
let { status } = await apiPut(`/users/0/items/${item.key}`, {
|
|
body: { itemType: 'book', title: 'x' },
|
|
successCodes: [428],
|
|
responseType: 'text'
|
|
});
|
|
assert.equal(status, 428);
|
|
await item.eraseTx();
|
|
});
|
|
|
|
it("should return 412 on stale version", async function () {
|
|
let item = await createDataObject('item');
|
|
let { status } = await apiPut(`/users/0/items/${item.key}`, {
|
|
body: {
|
|
itemType: 'book',
|
|
title: 'x',
|
|
version: item.clientVersion - 1,
|
|
},
|
|
successCodes: [412],
|
|
responseType: 'text'
|
|
});
|
|
assert.equal(status, 412);
|
|
await item.eraseTx();
|
|
});
|
|
|
|
it("should reject invalid key with 400", async function () {
|
|
let { status } = await apiPut('/users/0/items/badkey!!', {
|
|
body: { itemType: 'book' },
|
|
successCodes: [400],
|
|
responseType: 'text'
|
|
});
|
|
assert.equal(status, 400);
|
|
});
|
|
});
|
|
|
|
describe("PATCH <userOrGroupPrefix>/items/<key>", function () {
|
|
it("should leave unspecified fields untouched", async function () {
|
|
let item = await createDataObject('item', {
|
|
itemType: 'book',
|
|
setTitle: true,
|
|
});
|
|
item.setField('publisher', 'Keep Me');
|
|
await item.saveTx();
|
|
let savedTitle = item.getField('title');
|
|
|
|
let { status } = await apiPatch(`/users/0/items/${item.key}`, {
|
|
body: {
|
|
publisher: 'New Publisher',
|
|
version: item.clientVersion,
|
|
},
|
|
successCodes: [204]
|
|
});
|
|
assert.equal(status, 204);
|
|
|
|
let reloaded = await Zotero.Items.getByLibraryAndKeyAsync(
|
|
Zotero.Libraries.userLibraryID, item.key);
|
|
assert.equal(reloaded.getField('title'), savedTitle);
|
|
assert.equal(reloaded.getField('publisher'), 'New Publisher');
|
|
await item.eraseTx();
|
|
});
|
|
|
|
it("should clear a field when explicitly set to ''", async function () {
|
|
let item = await createDataObject('item', { itemType: 'book' });
|
|
item.setField('publisher', 'Will Be Cleared');
|
|
await item.saveTx();
|
|
let { status } = await apiPatch(`/users/0/items/${item.key}`, {
|
|
body: { publisher: '', version: item.clientVersion },
|
|
successCodes: [204]
|
|
});
|
|
assert.equal(status, 204);
|
|
let reloaded = await Zotero.Items.getByLibraryAndKeyAsync(
|
|
Zotero.Libraries.userLibraryID, item.key);
|
|
assert.equal(reloaded.getField('publisher'), '');
|
|
await item.eraseTx();
|
|
});
|
|
|
|
it("should accept If-Unmodified-Since-Version against the object version even after the library version advances", async function () {
|
|
let itemA = await createDataObject('item', { itemType: 'book' });
|
|
let versionA = itemA.clientVersion;
|
|
// Another write advances the library version past itemA's version
|
|
let itemB = await createDataObject('item', { itemType: 'book' });
|
|
assert.isAbove(itemB.clientVersion, versionA);
|
|
|
|
// itemA itself is unchanged, so a write with its version must succeed
|
|
let { status } = await apiPatch(`/users/0/items/${itemA.key}`, {
|
|
body: { publisher: 'Updated' },
|
|
headers: { 'If-Unmodified-Since-Version': String(versionA) },
|
|
successCodes: [204]
|
|
});
|
|
assert.equal(status, 204);
|
|
let reloaded = await Zotero.Items.getByLibraryAndKeyAsync(
|
|
Zotero.Libraries.userLibraryID, itemA.key);
|
|
assert.equal(reloaded.getField('publisher'), 'Updated');
|
|
await itemA.eraseTx();
|
|
await itemB.eraseTx();
|
|
});
|
|
});
|
|
|
|
describe("DELETE <userOrGroupPrefix>/items/<key>", function () {
|
|
it("should delete a single item", async function () {
|
|
let item = await createDataObject('item');
|
|
let versionBefore = Zotero.Libraries.userLibrary.clientVersion;
|
|
let xhr = await apiDelete(`/users/0/items/${item.key}`, {
|
|
headers: {
|
|
'If-Unmodified-Since-Version': String(item.clientVersion),
|
|
},
|
|
successCodes: [204]
|
|
});
|
|
assert.equal(xhr.status, 204);
|
|
let reloaded = await Zotero.Items.getByLibraryAndKeyAsync(
|
|
Zotero.Libraries.userLibraryID, item.key);
|
|
assert.isFalse(!!reloaded);
|
|
// The deletion advances the library version
|
|
assert.isAbove(
|
|
parseInt(xhr.getResponseHeader('Last-Modified-Version')),
|
|
versionBefore
|
|
);
|
|
});
|
|
|
|
it("should require If-Unmodified-Since-Version (428)", async function () {
|
|
let item = await createDataObject('item');
|
|
let { status } = await apiDelete(`/users/0/items/${item.key}`, {
|
|
successCodes: [428],
|
|
responseType: 'text'
|
|
});
|
|
assert.equal(status, 428);
|
|
await item.eraseTx();
|
|
});
|
|
|
|
it("should return 412 on stale If-Unmodified-Since-Version", async function () {
|
|
let item = await createDataObject('item');
|
|
let { status } = await apiDelete(`/users/0/items/${item.key}`, {
|
|
headers: {
|
|
'If-Unmodified-Since-Version': String(item.clientVersion - 1),
|
|
},
|
|
successCodes: [412],
|
|
responseType: 'text'
|
|
});
|
|
assert.equal(status, 412);
|
|
await item.eraseTx();
|
|
});
|
|
|
|
it("should return 404 for unknown key", async function () {
|
|
let { status } = await apiDelete('/users/0/items/ABCDEFGH', {
|
|
headers: { 'If-Unmodified-Since-Version': '999' },
|
|
successCodes: [404],
|
|
responseType: 'text'
|
|
});
|
|
assert.equal(status, 404);
|
|
});
|
|
});
|
|
|
|
describe("DELETE <userOrGroupPrefix>/items?itemKey=", function () {
|
|
it("should delete multiple items", async function () {
|
|
let item1 = await createDataObject('item');
|
|
let item2 = await createDataObject('item');
|
|
let { status } = await apiDelete(
|
|
`/users/0/items?itemKey=${item1.key},${item2.key}`,
|
|
{
|
|
headers: {
|
|
'If-Unmodified-Since-Version':
|
|
String(Zotero.Libraries.userLibrary.clientVersion),
|
|
},
|
|
successCodes: [204]
|
|
}
|
|
);
|
|
assert.equal(status, 204);
|
|
assert.isFalse(!!(await Zotero.Items.getByLibraryAndKeyAsync(
|
|
Zotero.Libraries.userLibraryID, item1.key)));
|
|
assert.isFalse(!!(await Zotero.Items.getByLibraryAndKeyAsync(
|
|
Zotero.Libraries.userLibraryID, item2.key)));
|
|
});
|
|
|
|
it("should ignore unknown keys mixed in", async function () {
|
|
let item = await createDataObject('item');
|
|
let { status } = await apiDelete(
|
|
`/users/0/items?itemKey=${item.key},ZZZZZZZZ`,
|
|
{
|
|
headers: {
|
|
'If-Unmodified-Since-Version':
|
|
String(Zotero.Libraries.userLibrary.clientVersion),
|
|
},
|
|
successCodes: [204]
|
|
}
|
|
);
|
|
assert.equal(status, 204);
|
|
});
|
|
|
|
it("should reject batches over 50 keys with 413", async function () {
|
|
let keys = [];
|
|
for (let i = 0; i < 51; i++) {
|
|
keys.push("AAAAAAA" + (i % 9 + 1));
|
|
}
|
|
let { status } = await apiDelete(
|
|
'/users/0/items?itemKey=' + keys.join(','),
|
|
{
|
|
headers: {
|
|
'If-Unmodified-Since-Version':
|
|
String(Zotero.Libraries.userLibrary.clientVersion),
|
|
},
|
|
successCodes: [413],
|
|
responseType: 'text'
|
|
}
|
|
);
|
|
assert.equal(status, 413);
|
|
});
|
|
});
|
|
|
|
describe("POST <userOrGroupPrefix>/collections", function () {
|
|
it("should create a collection", async function () {
|
|
let { response } = await apiPost('/users/0/collections', {
|
|
body: [{ name: 'New Test Collection' }],
|
|
headers: { 'Zotero-Write-Token': newWriteToken() }
|
|
});
|
|
assert.lengthOf(Object.keys(response.successful), 1);
|
|
let key = response.successful['0'].key;
|
|
let col = Zotero.Collections.getByLibraryAndKey(
|
|
Zotero.Libraries.userLibraryID, key);
|
|
assert.equal(col.name, 'New Test Collection');
|
|
await col.eraseTx();
|
|
});
|
|
|
|
it("should set parentCollection when provided", async function () {
|
|
let parent = await createDataObject('collection');
|
|
let { response } = await apiPost('/users/0/collections', {
|
|
body: [{ name: 'Child', parentCollection: parent.key }],
|
|
headers: { 'Zotero-Write-Token': newWriteToken() }
|
|
});
|
|
let key = response.successful['0'].key;
|
|
let child = Zotero.Collections.getByLibraryAndKey(
|
|
Zotero.Libraries.userLibraryID, key);
|
|
assert.equal(child.parentKey, parent.key);
|
|
await child.eraseTx();
|
|
await parent.eraseTx();
|
|
});
|
|
});
|
|
|
|
describe("PUT/PATCH/DELETE <userOrGroupPrefix>/collections/<key>", function () {
|
|
it("should update a collection via PUT", async function () {
|
|
let col = await createDataObject('collection', { name: 'Before' });
|
|
let { status } = await apiPut(`/users/0/collections/${col.key}`, {
|
|
body: {
|
|
version: col.clientVersion,
|
|
name: 'After PUT',
|
|
},
|
|
successCodes: [204]
|
|
});
|
|
assert.equal(status, 204);
|
|
let reloaded = Zotero.Collections.getByLibraryAndKey(
|
|
Zotero.Libraries.userLibraryID, col.key);
|
|
assert.equal(reloaded.name, 'After PUT');
|
|
await col.eraseTx();
|
|
});
|
|
|
|
it("should patch a collection's name", async function () {
|
|
let col = await createDataObject('collection', { name: 'Before' });
|
|
let { status } = await apiPatch(`/users/0/collections/${col.key}`, {
|
|
body: { name: 'After PATCH', version: col.clientVersion },
|
|
successCodes: [204]
|
|
});
|
|
assert.equal(status, 204);
|
|
let reloaded = Zotero.Collections.getByLibraryAndKey(
|
|
Zotero.Libraries.userLibraryID, col.key);
|
|
assert.equal(reloaded.name, 'After PATCH');
|
|
await col.eraseTx();
|
|
});
|
|
|
|
it("should delete a collection", async function () {
|
|
let col = await createDataObject('collection');
|
|
let { status } = await apiDelete(`/users/0/collections/${col.key}`, {
|
|
headers: {
|
|
'If-Unmodified-Since-Version': String(col.clientVersion),
|
|
},
|
|
successCodes: [204]
|
|
});
|
|
assert.equal(status, 204);
|
|
let reloaded = Zotero.Collections.getByLibraryAndKey(
|
|
Zotero.Libraries.userLibraryID, col.key);
|
|
assert.isFalse(!!reloaded);
|
|
});
|
|
});
|
|
|
|
describe("POST/PUT/DELETE <userOrGroupPrefix>/searches", function () {
|
|
it("should create a saved search", async function () {
|
|
let { response } = await apiPost('/users/0/searches', {
|
|
body: [{
|
|
name: 'My Saved Search',
|
|
conditions: [
|
|
{ condition: 'title', operator: 'contains', value: 'foo' }
|
|
]
|
|
}],
|
|
headers: { 'Zotero-Write-Token': newWriteToken() }
|
|
});
|
|
assert.lengthOf(Object.keys(response.successful), 1);
|
|
let key = response.successful['0'].key;
|
|
let search = Zotero.Searches.getByLibraryAndKey(
|
|
Zotero.Libraries.userLibraryID, key);
|
|
assert.equal(search.name, 'My Saved Search');
|
|
await search.eraseTx();
|
|
});
|
|
|
|
it("should delete a search", async function () {
|
|
let search = await createDataObject('search', { name: 'To Delete' });
|
|
let { status } = await apiDelete(`/users/0/searches/${search.key}`, {
|
|
headers: {
|
|
'If-Unmodified-Since-Version': String(search.clientVersion),
|
|
},
|
|
successCodes: [204]
|
|
});
|
|
assert.equal(status, 204);
|
|
let reloaded = Zotero.Searches.getByLibraryAndKey(
|
|
Zotero.Libraries.userLibraryID, search.key);
|
|
assert.isFalse(!!reloaded);
|
|
});
|
|
});
|
|
|
|
describe("DELETE <userOrGroupPrefix>/tags", function () {
|
|
it("should remove tags from the library", async function () {
|
|
let item = await createDataObject('item');
|
|
item.setTags(['tagToDelete', 'tagToKeep']);
|
|
await item.saveTx();
|
|
assert.isAtLeast(Zotero.Tags.getID('tagToDelete'), 1);
|
|
let itemVersionBefore = item.clientVersion;
|
|
|
|
let xhr = await apiDelete('/users/0/tags?tag=tagToDelete', {
|
|
headers: {
|
|
'If-Unmodified-Since-Version':
|
|
String(Zotero.Libraries.userLibrary.clientVersion),
|
|
},
|
|
successCodes: [204]
|
|
});
|
|
assert.equal(xhr.status, 204);
|
|
let reloaded = await Zotero.Items.getByLibraryAndKeyAsync(
|
|
Zotero.Libraries.userLibraryID, item.key);
|
|
assert.notInclude(reloaded.getTags().map(t => t.tag), 'tagToDelete');
|
|
assert.include(reloaded.getTags().map(t => t.tag), 'tagToKeep');
|
|
// The tag deletion advances the modified items' versions
|
|
assert.isAbove(reloaded.clientVersion, itemVersionBefore);
|
|
assert.equal(
|
|
parseInt(xhr.getResponseHeader('Last-Modified-Version')),
|
|
reloaded.clientVersion
|
|
);
|
|
await item.eraseTx();
|
|
});
|
|
|
|
it("should split tag names on ||", async function () {
|
|
let item = await createDataObject('item');
|
|
item.setTags(['multi1', 'multi2', 'keep']);
|
|
await item.saveTx();
|
|
let { status } = await apiDelete('/users/0/tags?tag=multi1||multi2', {
|
|
headers: {
|
|
'If-Unmodified-Since-Version':
|
|
String(Zotero.Libraries.userLibrary.clientVersion),
|
|
},
|
|
successCodes: [204]
|
|
});
|
|
assert.equal(status, 204);
|
|
let reloaded = await Zotero.Items.getByLibraryAndKeyAsync(
|
|
Zotero.Libraries.userLibraryID, item.key);
|
|
let tags = reloaded.getTags().map(t => t.tag);
|
|
assert.notInclude(tags, 'multi1');
|
|
assert.notInclude(tags, 'multi2');
|
|
assert.include(tags, 'keep');
|
|
await item.eraseTx();
|
|
});
|
|
|
|
it("should require If-Unmodified-Since-Version", async function () {
|
|
let { status } = await apiDelete('/users/0/tags?tag=anything', {
|
|
successCodes: [428],
|
|
responseType: 'text'
|
|
});
|
|
assert.equal(status, 428);
|
|
});
|
|
});
|
|
|
|
describe("PUT <userOrGroupPrefix>/items/<key>/fulltext", function () {
|
|
let parent;
|
|
let attachment;
|
|
|
|
beforeEach(async function () {
|
|
parent = await createDataObject('item');
|
|
attachment = await importPDFAttachment(parent);
|
|
});
|
|
|
|
afterEach(async function () {
|
|
if (attachment) await attachment.eraseTx();
|
|
if (parent) await parent.eraseTx();
|
|
});
|
|
|
|
it("should set fulltext content for an attachment", async function () {
|
|
let beforeVersion = Zotero.Libraries.userLibrary.clientVersion;
|
|
let xhr = await apiPut(
|
|
`/users/0/items/${attachment.key}/fulltext`,
|
|
{
|
|
body: {
|
|
content: 'Hello, fulltext.',
|
|
indexedPages: 1,
|
|
totalPages: 1,
|
|
}
|
|
}
|
|
);
|
|
assert.equal(xhr.status, 204);
|
|
let lastModified = parseInt(xhr.getResponseHeader('Last-Modified-Version'));
|
|
assert.isAbove(lastModified, beforeVersion);
|
|
|
|
let { response } = await apiGet(`/users/0/items/${attachment.key}/fulltext`);
|
|
assert.equal(response.content, 'Hello, fulltext.');
|
|
assert.equal(response.indexedPages, 1);
|
|
assert.equal(response.totalPages, 1);
|
|
});
|
|
|
|
it("should accept char-based stats", async function () {
|
|
let { status } = await apiPut(
|
|
`/users/0/items/${attachment.key}/fulltext`,
|
|
{
|
|
body: {
|
|
content: 'Char-counted content.',
|
|
indexedChars: 21,
|
|
totalChars: 21,
|
|
}
|
|
}
|
|
);
|
|
assert.equal(status, 204);
|
|
let { response } = await apiGet(`/users/0/items/${attachment.key}/fulltext`);
|
|
assert.equal(response.content, 'Char-counted content.');
|
|
assert.equal(response.indexedChars, 21);
|
|
assert.equal(response.totalChars, 21);
|
|
});
|
|
|
|
it("should reject a body without 'content'", async function () {
|
|
let { status } = await apiPut(
|
|
`/users/0/items/${attachment.key}/fulltext`,
|
|
{
|
|
body: { indexedPages: 1, totalPages: 1 },
|
|
successCodes: [400],
|
|
responseType: 'text'
|
|
}
|
|
);
|
|
assert.equal(status, 400);
|
|
});
|
|
|
|
it("should reject non-integer stats", async function () {
|
|
let { status } = await apiPut(
|
|
`/users/0/items/${attachment.key}/fulltext`,
|
|
{
|
|
body: { content: 'x', indexedPages: '1' },
|
|
successCodes: [400],
|
|
responseType: 'text'
|
|
}
|
|
);
|
|
assert.equal(status, 400);
|
|
});
|
|
|
|
it("should return 404 for unknown item key", async function () {
|
|
let { status } = await apiPut(
|
|
`/users/0/items/AAAAAAAA/fulltext`,
|
|
{
|
|
body: { content: 'x' },
|
|
successCodes: [404],
|
|
responseType: 'text'
|
|
}
|
|
);
|
|
assert.equal(status, 404);
|
|
});
|
|
|
|
it("should reject a non-attachment item", async function () {
|
|
let regularItem = await createDataObject('item');
|
|
try {
|
|
let { status } = await apiPut(
|
|
`/users/0/items/${regularItem.key}/fulltext`,
|
|
{
|
|
body: { content: 'x' },
|
|
successCodes: [404],
|
|
responseType: 'text'
|
|
}
|
|
);
|
|
assert.equal(status, 404);
|
|
}
|
|
finally {
|
|
await regularItem.eraseTx();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("POST <userOrGroupPrefix>/fulltext", function () {
|
|
let parent;
|
|
let a1;
|
|
let a2;
|
|
|
|
beforeEach(async function () {
|
|
parent = await createDataObject('item');
|
|
a1 = await importPDFAttachment(parent);
|
|
a2 = await importPDFAttachment(parent);
|
|
});
|
|
|
|
afterEach(async function () {
|
|
for (let item of [a1, a2, parent]) {
|
|
if (item) await item.eraseTx();
|
|
}
|
|
});
|
|
|
|
function iusvHeader() {
|
|
return { 'If-Unmodified-Since-Version': String(Zotero.Libraries.userLibrary.clientVersion) };
|
|
}
|
|
|
|
it("should write multiple items and return a write report", async function () {
|
|
let { status, response } = await apiPost('/users/0/fulltext', {
|
|
headers: iusvHeader(),
|
|
body: [
|
|
{ key: a1.key, content: 'one', indexedPages: 1, totalPages: 1 },
|
|
{ key: a2.key, content: 'two', indexedPages: 1, totalPages: 1 },
|
|
]
|
|
});
|
|
assert.equal(status, 200);
|
|
assert.deepEqual(response.successful['0'], { key: a1.key });
|
|
assert.deepEqual(response.successful['1'], { key: a2.key });
|
|
assert.equal(response.success['0'], a1.key);
|
|
assert.equal(response.success['1'], a2.key);
|
|
assert.isEmpty(Object.keys(response.failed));
|
|
|
|
let { response: r1 } = await apiGet(`/users/0/items/${a1.key}/fulltext`);
|
|
assert.equal(r1.content, 'one');
|
|
let { response: r2 } = await apiGet(`/users/0/items/${a2.key}/fulltext`);
|
|
assert.equal(r2.content, 'two');
|
|
});
|
|
|
|
it("should report per-item failures while applying the rest", async function () {
|
|
let regularItem = await createDataObject('item');
|
|
try {
|
|
let { status, response } = await apiPost('/users/0/fulltext', {
|
|
headers: iusvHeader(),
|
|
body: [
|
|
{ key: a1.key, content: 'ok' },
|
|
{ key: 'AAAAAAAA', content: 'missing' },
|
|
{ key: regularItem.key, content: 'not-attach' },
|
|
]
|
|
});
|
|
assert.equal(status, 200);
|
|
assert.property(response.successful, '0');
|
|
assert.notProperty(response.successful, '1');
|
|
assert.notProperty(response.successful, '2');
|
|
assert.equal(response.failed['1'].code, 404);
|
|
assert.equal(response.failed['2'].code, 404);
|
|
}
|
|
finally {
|
|
await regularItem.eraseTx();
|
|
}
|
|
});
|
|
|
|
it("should require If-Unmodified-Since-Version (428)", async function () {
|
|
let { status } = await apiPost('/users/0/fulltext', {
|
|
body: [{ key: a1.key, content: 'x' }],
|
|
successCodes: [428],
|
|
responseType: 'text'
|
|
});
|
|
assert.equal(status, 428);
|
|
});
|
|
|
|
it("should reject non-array body with 400", async function () {
|
|
let { status } = await apiPost('/users/0/fulltext', {
|
|
headers: iusvHeader(),
|
|
body: { key: a1.key, content: 'x' },
|
|
successCodes: [400],
|
|
responseType: 'text'
|
|
});
|
|
assert.equal(status, 400);
|
|
});
|
|
|
|
it("should reject batches over 10 with 413", async function () {
|
|
let big = [];
|
|
for (let i = 0; i < 11; i++) {
|
|
big.push({ key: a1.key, content: 'x' });
|
|
}
|
|
let { status } = await apiPost('/users/0/fulltext', {
|
|
headers: iusvHeader(),
|
|
body: big,
|
|
successCodes: [413],
|
|
responseType: 'text'
|
|
});
|
|
assert.equal(status, 413);
|
|
});
|
|
|
|
it("should reject an entry missing 'key'", async function () {
|
|
let { status, response } = await apiPost('/users/0/fulltext', {
|
|
headers: iusvHeader(),
|
|
body: [{ content: 'x' }]
|
|
});
|
|
assert.equal(status, 200);
|
|
assert.equal(response.failed['0'].code, 400);
|
|
});
|
|
});
|
|
|
|
describe("File uploads", function () {
|
|
let attachment;
|
|
|
|
beforeEach(async function () {
|
|
let parent = await createDataObject('item');
|
|
attachment = await Zotero.Attachments.importFromFile({
|
|
file: OS.Path.join(getTestDataDirectory().path, 'test.pdf'),
|
|
parentItemID: parent.id,
|
|
});
|
|
});
|
|
|
|
afterEach(async function () {
|
|
if (attachment) {
|
|
let parent = Zotero.Items.get(attachment.parentItemID);
|
|
await attachment.eraseTx();
|
|
if (parent) await parent.eraseTx();
|
|
}
|
|
});
|
|
|
|
it("should return {exists:1} when MD5 matches local file", async function () {
|
|
let path = await attachment.getFilePathAsync();
|
|
let md5 = await Zotero.Utilities.Internal.md5Async(path);
|
|
let stat = await IOUtils.stat(path);
|
|
|
|
let { response } = await apiPost(
|
|
`/users/0/items/${attachment.key}/file`,
|
|
{
|
|
body: `md5=${md5}&filename=${encodeURIComponent(attachment.attachmentFilename)}&filesize=${stat.size}&mtime=${stat.lastModified}`,
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'If-None-Match': '*',
|
|
}
|
|
}
|
|
);
|
|
assert.equal(response.exists, 1);
|
|
});
|
|
|
|
it("should authorize a new upload when MD5 differs", async function () {
|
|
let newMD5 = 'a'.repeat(32);
|
|
let { response } = await apiPost(
|
|
`/users/0/items/${attachment.key}/file`,
|
|
{
|
|
body: `md5=${newMD5}&filename=newfile.pdf&filesize=10&mtime=${Date.now()}`,
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'If-None-Match': '*',
|
|
}
|
|
}
|
|
);
|
|
assert.isString(response.url);
|
|
assert.isString(response.uploadKey);
|
|
assert.include(response.url, '/api/local/uploads/');
|
|
assert.property(response, 'prefix');
|
|
assert.property(response, 'suffix');
|
|
});
|
|
|
|
it("should return params object when params=1 is in the body", async function () {
|
|
let { response } = await apiPost(
|
|
`/users/0/items/${attachment.key}/file`,
|
|
{
|
|
body: `md5=${'b'.repeat(32)}&filename=x.pdf&filesize=10&mtime=${Date.now()}¶ms=1`,
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'If-None-Match': '*',
|
|
}
|
|
}
|
|
);
|
|
assert.isObject(response.params);
|
|
assert.isString(response.uploadKey);
|
|
});
|
|
|
|
it("should reject mtime in seconds with 400", async function () {
|
|
let { status } = await apiPost(
|
|
`/users/0/items/${attachment.key}/file`,
|
|
{
|
|
body: `md5=${'c'.repeat(32)}&filename=x.pdf&filesize=10&mtime=1000`,
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'If-None-Match': '*',
|
|
},
|
|
successCodes: [400],
|
|
responseType: 'text'
|
|
}
|
|
);
|
|
assert.equal(status, 400);
|
|
});
|
|
|
|
it("should require If-Match or If-None-Match with 428", async function () {
|
|
let { status } = await apiPost(
|
|
`/users/0/items/${attachment.key}/file`,
|
|
{
|
|
body: `md5=${'d'.repeat(32)}&filename=x.pdf&filesize=10&mtime=${Date.now()}`,
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
},
|
|
successCodes: [428],
|
|
responseType: 'text'
|
|
}
|
|
);
|
|
assert.equal(status, 428);
|
|
});
|
|
|
|
it("should reject bad MD5 with 400", async function () {
|
|
let { status } = await apiPost(
|
|
`/users/0/items/${attachment.key}/file`,
|
|
{
|
|
body: `md5=not-a-real-md5&filename=x.pdf&filesize=10&mtime=${Date.now()}`,
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'If-None-Match': '*',
|
|
},
|
|
successCodes: [400],
|
|
responseType: 'text'
|
|
}
|
|
);
|
|
assert.equal(status, 400);
|
|
});
|
|
|
|
it("should reject a filename containing path separators with 400", async function () {
|
|
for (let filename of ['../escape.txt', 'sub/escape.txt', '..\\escape.txt', '..']) {
|
|
let { status } = await apiPost(
|
|
`/users/0/items/${attachment.key}/file`,
|
|
{
|
|
body: `md5=${'a'.repeat(32)}&filename=${encodeURIComponent(filename)}`
|
|
+ `&filesize=10&mtime=${Date.now()}`,
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'If-None-Match': '*',
|
|
},
|
|
successCodes: [400],
|
|
responseType: 'text'
|
|
}
|
|
);
|
|
assert.equal(status, 400, `expected 400 for filename '${filename}'`);
|
|
}
|
|
});
|
|
|
|
it("should perform a full upload + register flow", async function () {
|
|
// Generate new file content with a known MD5
|
|
let content = "Local API upload test content " + Date.now();
|
|
let encoder = new TextEncoder();
|
|
let bytes = encoder.encode(content);
|
|
// Compute the MD5 we will claim
|
|
let tmpDir = Zotero.getTempDirectory().path;
|
|
let tmpPath = PathUtils.join(tmpDir, 'localapi-upload-test.bin');
|
|
await IOUtils.write(tmpPath, bytes);
|
|
let md5 = await Zotero.Utilities.Internal.md5Async(tmpPath);
|
|
await IOUtils.remove(tmpPath);
|
|
|
|
let mtime = Date.now();
|
|
|
|
// Phase 1: authorize
|
|
let { response: authResp } = await apiPost(
|
|
`/users/0/items/${attachment.key}/file`,
|
|
{
|
|
body: `md5=${md5}&filename=uploaded.bin&filesize=${bytes.length}&mtime=${mtime}`,
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'If-None-Match': '*',
|
|
}
|
|
}
|
|
);
|
|
assert.isString(authResp.uploadKey);
|
|
let uploadKey = authResp.uploadKey;
|
|
|
|
// Phase 2: upload bytes to local receiver
|
|
let uploadXhr = await Zotero.HTTP.request(
|
|
'POST', authResp.url,
|
|
{
|
|
headers: {
|
|
'Zotero-Allowed-Request': '1',
|
|
'Content-Type': 'application/octet-stream',
|
|
'Zotero-Server-ID': serverID,
|
|
},
|
|
body: content,
|
|
successCodes: [201],
|
|
responseType: 'text'
|
|
}
|
|
);
|
|
assert.equal(uploadXhr.status, 201);
|
|
|
|
// Phase 3: register
|
|
let { status } = await apiPost(
|
|
`/users/0/items/${attachment.key}/file`,
|
|
{
|
|
body: `upload=${uploadKey}`,
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'If-None-Match': '*',
|
|
},
|
|
successCodes: [204]
|
|
}
|
|
);
|
|
assert.equal(status, 204);
|
|
|
|
// Verify attachment metadata was updated
|
|
let reloaded = await Zotero.Items.getByLibraryAndKeyAsync(
|
|
Zotero.Libraries.userLibraryID, attachment.key);
|
|
assert.equal(reloaded.attachmentSyncedHash, md5);
|
|
assert.equal(reloaded.attachmentSyncedModificationTime, mtime);
|
|
assert.equal(reloaded.attachmentFilename, 'uploaded.bin');
|
|
});
|
|
|
|
it("shouldn't modify the existing file until registration succeeds", async function () {
|
|
let path = await attachment.getFilePathAsync();
|
|
let originalContents = await IOUtils.read(path);
|
|
|
|
let content = "Replacement content for staged upload test";
|
|
let bytes = new TextEncoder().encode(content);
|
|
let tmpPath = PathUtils.join(Zotero.getTempDirectory().path, 'localapi-staged-test.bin');
|
|
await IOUtils.write(tmpPath, bytes);
|
|
let md5 = await Zotero.Utilities.Internal.md5Async(tmpPath);
|
|
await IOUtils.remove(tmpPath);
|
|
|
|
// Authorize and upload with the existing filename
|
|
let { response: authResp } = await apiPost(
|
|
`/users/0/items/${attachment.key}/file`,
|
|
{
|
|
body: `md5=${md5}&filename=${encodeURIComponent(attachment.attachmentFilename)}`
|
|
+ `&filesize=${bytes.length}&mtime=${Date.now()}`,
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'If-None-Match': '*',
|
|
}
|
|
}
|
|
);
|
|
await Zotero.HTTP.request('POST', authResp.url, {
|
|
headers: {
|
|
'Zotero-Allowed-Request': '1',
|
|
'Content-Type': 'application/octet-stream',
|
|
'Zotero-Server-ID': serverID,
|
|
},
|
|
body: content,
|
|
successCodes: [201],
|
|
responseType: 'text'
|
|
});
|
|
|
|
// The bytes have been received but not registered, so the existing file
|
|
// must be untouched
|
|
assert.deepEqual(await IOUtils.read(path), originalContents);
|
|
|
|
// Register, after which the file is replaced
|
|
await apiPost(
|
|
`/users/0/items/${attachment.key}/file`,
|
|
{
|
|
body: `upload=${authResp.uploadKey}`,
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'If-None-Match': '*',
|
|
},
|
|
successCodes: [204]
|
|
}
|
|
);
|
|
assert.equal(
|
|
await Zotero.Utilities.Internal.md5Async(await attachment.getFilePathAsync()),
|
|
md5
|
|
);
|
|
});
|
|
|
|
it("shouldn't modify the existing file when registration fails with 412", async function () {
|
|
let path = await attachment.getFilePathAsync();
|
|
let originalContents = await IOUtils.read(path);
|
|
|
|
let content = "Content that must never be registered";
|
|
let bytes = new TextEncoder().encode(content);
|
|
let tmpPath = PathUtils.join(Zotero.getTempDirectory().path, 'localapi-staged-412-test.bin');
|
|
await IOUtils.write(tmpPath, bytes);
|
|
let md5 = await Zotero.Utilities.Internal.md5Async(tmpPath);
|
|
await IOUtils.remove(tmpPath);
|
|
|
|
let { response: authResp } = await apiPost(
|
|
`/users/0/items/${attachment.key}/file`,
|
|
{
|
|
body: `md5=${md5}&filename=${encodeURIComponent(attachment.attachmentFilename)}`
|
|
+ `&filesize=${bytes.length}&mtime=${Date.now()}`,
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'If-None-Match': '*',
|
|
}
|
|
}
|
|
);
|
|
await Zotero.HTTP.request('POST', authResp.url, {
|
|
headers: {
|
|
'Zotero-Allowed-Request': '1',
|
|
'Content-Type': 'application/octet-stream',
|
|
'Zotero-Server-ID': serverID,
|
|
},
|
|
body: content,
|
|
successCodes: [201],
|
|
responseType: 'text'
|
|
});
|
|
|
|
// Another client registers a file for this attachment in the meantime
|
|
attachment.attachmentSyncedHash = 'f'.repeat(32);
|
|
await attachment.saveTx();
|
|
|
|
let { status } = await apiPost(
|
|
`/users/0/items/${attachment.key}/file`,
|
|
{
|
|
body: `upload=${authResp.uploadKey}`,
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'If-None-Match': '*',
|
|
},
|
|
successCodes: [412],
|
|
responseType: 'text'
|
|
}
|
|
);
|
|
assert.equal(status, 412);
|
|
assert.deepEqual(await IOUtils.read(path), originalContents);
|
|
});
|
|
|
|
// Exercises the params=1 multipart upload form: each returned param as a form
|
|
// field, then the file bytes in a final `file` field. Uses real binary content
|
|
// to confirm it survives the multipart path.
|
|
it("should perform a params=1 multipart upload + register flow", async function () {
|
|
// Binary content spanning the byte range, ending in a newline byte to
|
|
// confirm the multipart parser doesn't trim/corrupt the file body
|
|
let raw = [0x25, 0x50, 0x44, 0x46, 0x00, 0x7f, 0x80, 0xff, 0xd0, 0x0a];
|
|
let bytes = Uint8Array.from(raw);
|
|
let tmpDir = Zotero.getTempDirectory().path;
|
|
let tmpPath = PathUtils.join(tmpDir, 'localapi-mp-upload-test.bin');
|
|
await IOUtils.write(tmpPath, bytes);
|
|
let md5 = await Zotero.Utilities.Internal.md5Async(tmpPath);
|
|
await IOUtils.remove(tmpPath);
|
|
|
|
let mtime = Date.now();
|
|
|
|
// Phase 1: authorize with params=1 in the form body
|
|
let { response: authResp } = await apiPost(
|
|
`/users/0/items/${attachment.key}/file`,
|
|
{
|
|
body: `md5=${md5}&filename=uploaded.bin&filesize=${bytes.length}`
|
|
+ `&mtime=${mtime}¶ms=1`,
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'If-None-Match': '*',
|
|
}
|
|
}
|
|
);
|
|
assert.isObject(authResp.params);
|
|
assert.isString(authResp.uploadKey);
|
|
|
|
// Phase 2: upload as multipart/form-data via FormData, with the returned
|
|
// params first, then the file part
|
|
let formData = new FormData();
|
|
for (let [key, val] of Object.entries(authResp.params)) {
|
|
formData.append(key, val);
|
|
}
|
|
formData.append('file', new Blob([bytes]), 'uploaded.bin');
|
|
|
|
let uploadXhr = await Zotero.HTTP.request('POST', authResp.url, {
|
|
headers: {
|
|
'Zotero-Allowed-Request': '1',
|
|
'Content-Type': 'multipart/form-data',
|
|
'Zotero-Server-ID': serverID,
|
|
},
|
|
body: formData,
|
|
successCodes: [201],
|
|
responseType: 'text'
|
|
});
|
|
assert.equal(uploadXhr.status, 201);
|
|
|
|
// Phase 3: register
|
|
let { status } = await apiPost(
|
|
`/users/0/items/${attachment.key}/file`,
|
|
{
|
|
body: `upload=${authResp.uploadKey}`,
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'If-None-Match': '*',
|
|
},
|
|
successCodes: [204]
|
|
}
|
|
);
|
|
assert.equal(status, 204);
|
|
|
|
let reloaded = await Zotero.Items.getByLibraryAndKeyAsync(
|
|
Zotero.Libraries.userLibraryID, attachment.key);
|
|
assert.equal(reloaded.attachmentSyncedHash, md5);
|
|
assert.equal(reloaded.attachmentFilename, 'uploaded.bin');
|
|
// The stored file's bytes must match exactly
|
|
let storedPath = await reloaded.getFilePathAsync();
|
|
let stored = await IOUtils.read(storedPath);
|
|
assert.deepEqual(Array.from(stored), raw);
|
|
});
|
|
|
|
it("should reject registration without prior upload", async function () {
|
|
// Authorize but never upload, then try to register
|
|
let { response: authResp } = await apiPost(
|
|
`/users/0/items/${attachment.key}/file`,
|
|
{
|
|
body: `md5=${'e'.repeat(32)}&filename=x.bin&filesize=5&mtime=${Date.now()}`,
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'If-None-Match': '*',
|
|
}
|
|
}
|
|
);
|
|
let { status } = await apiPost(
|
|
`/users/0/items/${attachment.key}/file`,
|
|
{
|
|
body: `upload=${authResp.uploadKey}`,
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'If-None-Match': '*',
|
|
},
|
|
successCodes: [400],
|
|
responseType: 'text'
|
|
}
|
|
);
|
|
assert.equal(status, 400);
|
|
});
|
|
|
|
it("should reject upload-receiver with mismatched MD5", async function () {
|
|
// Authorize with one MD5, then upload different content
|
|
let claimedMD5 = 'a'.repeat(32);
|
|
let { response: authResp } = await apiPost(
|
|
`/users/0/items/${attachment.key}/file`,
|
|
{
|
|
body: `md5=${claimedMD5}&filename=mm.bin&filesize=10&mtime=${Date.now()}`,
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'If-None-Match': '*',
|
|
}
|
|
}
|
|
);
|
|
let { status } = await Zotero.HTTP.request(
|
|
'POST', authResp.url,
|
|
{
|
|
headers: {
|
|
'Zotero-Allowed-Request': '1',
|
|
'Content-Type': 'application/octet-stream',
|
|
'Zotero-Server-ID': serverID,
|
|
},
|
|
body: 'wrong content',
|
|
successCodes: [400],
|
|
responseType: 'text'
|
|
}
|
|
);
|
|
assert.equal(status, 400);
|
|
});
|
|
|
|
it("should return 404 when upload key is unknown", async function () {
|
|
let { status } = await Zotero.HTTP.request(
|
|
'POST', apiRoot + '/local/uploads/badkey999',
|
|
{
|
|
headers: {
|
|
'Zotero-Allowed-Request': '1',
|
|
'Content-Type': 'application/octet-stream',
|
|
'Zotero-Server-ID': serverID,
|
|
},
|
|
body: 'whatever',
|
|
successCodes: [404],
|
|
responseType: 'text'
|
|
}
|
|
);
|
|
assert.equal(status, 404);
|
|
});
|
|
|
|
it("shouldn't require a server ID on the upload request", async function () {
|
|
// Web API clients post the bytes to S3, not to Zotero, so they strip
|
|
// Zotero-specific headers from this request -- it has to work without one
|
|
let content = "Upload sent without a Zotero-Server-ID header";
|
|
let bytes = new TextEncoder().encode(content);
|
|
let tmpPath = PathUtils.join(Zotero.getTempDirectory().path, 'localapi-no-server-id.bin');
|
|
await IOUtils.write(tmpPath, bytes);
|
|
let md5 = await Zotero.Utilities.Internal.md5Async(tmpPath);
|
|
await IOUtils.remove(tmpPath);
|
|
|
|
let { response: authResp } = await apiPost(
|
|
`/users/0/items/${attachment.key}/file`,
|
|
{
|
|
body: `md5=${md5}&filename=no-server-id.bin`
|
|
+ `&filesize=${bytes.length}&mtime=${Date.now()}`,
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'If-None-Match': '*',
|
|
}
|
|
}
|
|
);
|
|
|
|
let uploadXhr = await Zotero.HTTP.request('POST', authResp.url, {
|
|
headers: {
|
|
'Zotero-Allowed-Request': '1',
|
|
'Content-Type': 'application/octet-stream',
|
|
},
|
|
body: content,
|
|
successCodes: [201],
|
|
responseType: 'text'
|
|
});
|
|
assert.equal(uploadXhr.status, 201);
|
|
|
|
// And the upload is usable, so the flow completes as normal
|
|
let { status } = await apiPost(
|
|
`/users/0/items/${attachment.key}/file`,
|
|
{
|
|
body: `upload=${authResp.uploadKey}`,
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'If-None-Match': '*',
|
|
},
|
|
successCodes: [204]
|
|
}
|
|
);
|
|
assert.equal(status, 204);
|
|
assert.equal(
|
|
await Zotero.Utilities.Internal.md5Async(await attachment.getFilePathAsync()),
|
|
md5
|
|
);
|
|
});
|
|
|
|
it("should reject an upload with a mismatched server ID with 412", async function () {
|
|
let { status } = await Zotero.HTTP.request(
|
|
'POST', apiRoot + '/local/uploads/badkey999',
|
|
{
|
|
headers: {
|
|
'Zotero-Allowed-Request': '1',
|
|
'Content-Type': 'application/octet-stream',
|
|
'Zotero-Server-ID': 'wrongServerID',
|
|
},
|
|
body: 'whatever',
|
|
successCodes: [412],
|
|
responseType: 'text'
|
|
}
|
|
);
|
|
assert.equal(status, 412);
|
|
});
|
|
|
|
it("should reject PATCH partial upload with 405", async function () {
|
|
let { status } = await apiPatch(
|
|
`/users/0/items/${attachment.key}/file?algorithm=xdelta&upload=foo`,
|
|
{
|
|
headers: {
|
|
'Content-Type': 'application/octet-stream',
|
|
},
|
|
body: 'diff',
|
|
successCodes: [405],
|
|
responseType: 'text'
|
|
}
|
|
);
|
|
assert.equal(status, 405);
|
|
});
|
|
});
|
|
|
|
describe("Method restrictions", function () {
|
|
it("should reject POST to /items/top with 400 or 405", async function () {
|
|
let { status } = await apiPost('/users/0/items/top', {
|
|
body: [{ itemType: 'book' }],
|
|
headers: { 'Zotero-Write-Token': newWriteToken() },
|
|
successCodes: [400, 405],
|
|
responseType: 'text'
|
|
});
|
|
assert.oneOf(status, [400, 405]);
|
|
});
|
|
|
|
it("should reject DELETE to /items/trash with 400 or 405", async function () {
|
|
let { status } = await apiDelete(
|
|
'/users/0/items/trash?itemKey=ABCDEFGH',
|
|
{
|
|
headers: { 'If-Unmodified-Since-Version': '0' },
|
|
successCodes: [400, 405],
|
|
responseType: 'text'
|
|
}
|
|
);
|
|
assert.oneOf(status, [400, 405]);
|
|
});
|
|
});
|
|
});
|
|
});
|