zotero/test/tests/httpTest.js
Dan Stillman 364181f4e7 Send cookies and Referer from HTTP.download()
Since the switch to fetch() in 0fe31b0f04 (Zotero 10.0.0), download
requests didn't use cookies, and the Referer header was silently dropped
as a forbidden header. Sites that check either -- e.g., IEEE Xplore,
which returns a 502 -- failed during Find Full Text.

https://forums.zotero.org/discussion/133703/
2026-10-05 14:18:34 -04:00

877 lines
24 KiB
JavaScript

describe("Zotero.HTTP", function () {
var server;
var httpd;
var port = 16213;
var baseURL = `http://127.0.0.1:${port}/`
var testURL = baseURL + 'test.html';
var redirectLocation = baseURL + 'test2.html';
function setResponse(response) {
setHTTPResponse(server, baseURL, response, {});
}
before(function* () {
// Real HTTP server
var { HttpServer } = ChromeUtils.importESModule("chrome://remote/content/server/httpd.sys.mjs");;
httpd = new HttpServer();
httpd.start(port);
httpd.registerPathHandler(
'/test.html',
{
handle: function (request, response) {
response.setStatusLine(null, 200, "OK");
response.write("<html><body><p>Test</p><p>Test 2</p></body></html>");
}
}
);
httpd.registerPathHandler(
'/redirect',
{
handle: function (request, response) {
response.setHeader('Location', redirectLocation);
response.setHeader('X-Custom', 'redirect-value', false);
response.setStatusLine(null, 301, "Moved Permanently");
response.write(`<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">\n<html><head>\n<title>301 Moved Permanently</title>\n</head><body>\n<h1>Moved Permanently</h1>\n<p>The document has moved <a href="${redirectLocation}">here</a>.</p>\n</body></html>`);
}
}
);
httpd.registerPathHandler(
'/test-redirect.html',
{
handle: function (request, response) {
response.setHeader("Content-Type", 'text/html', false);
response.setStatusLine(null, 200, "OK");
response.write("<html><head><meta http-equiv=\"refresh\" content=\"2;url=test.html\"/></head><body></body></html>");
}
}
);
httpd.registerPathHandler(
'/requireJSON',
{
handle(request, response) {
if (request.getHeader('Content-Type') == 'application/json') {
response.setStatusLine(null, 200, "OK");
}
else {
response.setStatusLine(null, 400, "Bad Request");
}
response.write('JSON required');
}
}
);
// Returns 200 if the "authed" cookie is present, 403 otherwise.
// Always sets the "authed" cookie in the response.
httpd.registerPathHandler(
'/cookie-check',
{
handle(request, response) {
let hasCookie = false;
try {
let cookies = request.getHeader('Cookie');
hasCookie = cookies.includes('authed=yes');
}
catch {}
response.setHeader('Set-Cookie', 'authed=yes; Path=/', false);
if (hasCookie) {
response.setStatusLine(null, 200, "OK");
response.write("authenticated");
}
else {
response.setStatusLine(null, 403, "Forbidden");
response.write("no cookie");
}
}
}
);
});
beforeEach(function () {
// Fake XHR, which can be disabled per test with `Zotero.HTTP.mock = null`
Zotero.HTTP.mock = sinon.FakeXMLHttpRequest;
server = sinon.fakeServer.create();
server.autoRespond = true;
});
afterEach(async function () {
// Allow requests to settle
await Zotero.Promise.delay(50);
});
after(function* () {
var defer = Zotero.Promise.defer();
httpd.stop(() => defer.resolve());
yield defer.promise;
Zotero.HTTP.mock = null;
});
describe("#request()", function () {
it("should succeed with 3xx status if followRedirects is false", async function () {
Zotero.HTTP.mock = null;
var req = await Zotero.HTTP.request(
'GET',
baseURL + 'redirect',
{
followRedirects: false
}
);
assert.equal(req.status, 301);
assert.equal(req.getResponseHeader('Location'), redirectLocation);
assert.equal(req.getResponseHeader('X-Custom'), 'redirect-value');
});
it("should catch an interrupted connection", async function () {
setResponse({
method: "GET",
url: "empty",
status: 0,
text: ""
})
var e = await getPromiseError(Zotero.HTTP.request("GET", baseURL + "empty"));
assert.ok(e);
assert.equal(e.message, Zotero.getString('sync.error.checkConnection'));
});
it("should provide cancellerReceiver with a callback to cancel a request", async function () {
var timeoutID;
server.autoRespond = false;
server.respondWith(function (req) {
if (req.method == "GET" && req.url == baseURL + "slow") {
req.respond(
200,
{},
"OK"
);
}
});
setTimeout(function () {
cancel();
}, 50);
var e = await getPromiseError(Zotero.HTTP.request(
"GET",
baseURL + "slow",
{
cancellerReceiver: function (f) {
cancel = f;
}
}
));
assert.instanceOf(e, Zotero.HTTP.CancelledException);
server.respond();
});
it("should process headers case insensitively", async function () {
Zotero.HTTP.mock = null;
var req = await Zotero.HTTP.request(
'GET',
baseURL + 'requireJSON',
{
headers: {
'content-type': 'application/json'
}
}
);
assert.equal(req.status, 200);
});
describe("Retries", function () {
var spy;
var delayStub;
before(async function () {
// Wait for proxy auth probing to finish so its
// Zotero.Promise.delay() calls don't pollute the stub
await Zotero.proxyAuthComplete;
});
beforeEach(function () {
delayStub = sinon.stub(Zotero.Promise, "delay").returns(Promise.resolve());
});
afterEach(function () {
if (spy) {
spy.restore();
}
delayStub.restore();
});
after(function () {
sinon.restore();
});
it("should retry on 500 error", async function () {
setResponse({
method: "GET",
url: "error",
status: 500,
text: ""
});
spy = sinon.spy(Zotero.HTTP, "_requestInternal");
var e = await getPromiseError(
Zotero.HTTP.request(
"GET",
baseURL + "error",
{
errorDelayIntervals: [10, 20, 100],
errorDelayMax: 35
}
)
);
assert.instanceOf(e, Zotero.HTTP.UnexpectedStatusException);
assert.isTrue(spy.calledThrice);
assert.isTrue(delayStub.calledTwice);
assert.equal(delayStub.args[0][0], 10);
assert.equal(delayStub.args[1][0], 20);
});
it("shouldn't retry on 500 error if errorDelayMax=0", async function () {
setResponse({
method: "GET",
url: "error",
status: 500,
text: ""
});
spy = sinon.spy(Zotero.HTTP, "_requestInternal");
var e = await getPromiseError(
Zotero.HTTP.request(
"GET",
baseURL + "error",
{
errorDelayIntervals: [10, 20, 100],
errorDelayMax: 0
}
)
);
assert.instanceOf(e, Zotero.HTTP.UnexpectedStatusException);
assert.isTrue(spy.calledOnce);
assert.isTrue(delayStub.notCalled);
});
it("should provide cancellerReceiver a callback to cancel while waiting to retry a 5xx error", async function () {
delayStub.restore();
setResponse({
method: "GET",
url: "error",
status: 500,
text: ""
});
var cancel;
spy = sinon.spy(Zotero.HTTP, "_requestInternal");
setTimeout(() => {
cancel();
}, 300);
var e = await getPromiseError(
Zotero.HTTP.request(
"GET",
baseURL + "error",
{
errorDelayIntervals: [10, 10, 600],
cancellerReceiver: function () {
cancel = arguments[0];
}
}
)
);
assert.instanceOf(e, Zotero.HTTP.CancelledException);
assert.equal(spy.callCount, 3);
});
it("should obey Retry-After for 429", async function () {
var called = 0;
server.respond(function (req) {
if (req.method == "GET" && req.url == baseURL + "error") {
if (called < 1) {
req.respond(
429,
{
"Retry-After": "5"
},
""
);
}
else {
req.respond(
200,
{},
""
);
}
}
called++;
});
spy = sinon.spy(Zotero.HTTP, "_requestInternal");
await Zotero.HTTP.request("GET", baseURL + "error");
assert.equal(2, spy.callCount);
assert.approximately(delayStub.args[0][0], 5 * 1000, 5);
});
it("should obey Retry-After for 503", async function () {
var called = 0;
server.respond(function (req) {
if (req.method == "GET" && req.url == baseURL + "error") {
if (called < 1) {
req.respond(
503,
{
"Retry-After": "5"
},
""
);
}
else if (called < 2) {
req.respond(
503,
{
"Retry-After": "10"
},
""
);
}
else {
req.respond(
200,
{},
""
);
}
}
called++;
});
spy = sinon.spy(Zotero.HTTP, "_requestInternal");
await Zotero.HTTP.request("GET", baseURL + "error");
assert.equal(3, spy.callCount);
// DEBUG: Why are these slightly off?
assert.approximately(delayStub.args[0][0], 5 * 1000, 5);
assert.approximately(delayStub.args[1][0], 10 * 1000, 5);
});
it("should start with first interval on new request() call", async function () {
var called = 0;
server.respond(function (req) {
if (req.method == "GET" && req.url.startsWith(baseURL + "error")) {
if (called < 1) {
req.respond(500, {}, "");
}
else {
req.respond(200, {}, "");
}
}
called++;
});
spy = sinon.spy(Zotero.HTTP, "_requestInternal");
var errorDelayIntervals = [20];
await Zotero.HTTP.request("GET", baseURL + "error1", { errorDelayIntervals })
called = 0;
await Zotero.HTTP.request("GET", baseURL + "error2", { errorDelayIntervals }),
assert.equal(4, spy.callCount);
assert.equal(delayStub.args[0][0], 20);
assert.equal(delayStub.args[1][0], 20);
});
});
});
describe("#download()", function () {
var tmpDir;
before(function () {
httpd.registerPathHandler(
'/download/small.bin',
{
handle: function (request, response) {
response.setStatusLine(null, 200, "OK");
response.setHeader("Content-Type", "application/octet-stream", false);
let data = "abc".repeat(1024);
response.setHeader("Content-Length", String(data.length), false);
response.write(data);
}
}
);
httpd.registerPathHandler(
'/download/large.bin',
{
handle: function (request, response) {
response.setStatusLine(null, 200, "OK");
response.setHeader("Content-Type", "application/octet-stream", false);
// 256 KB -- enough to exercise multiple onDataAvailable calls
let chunk = "x".repeat(1024);
for (let i = 0; i < 256; i++) {
response.write(chunk);
}
}
}
);
httpd.registerPathHandler(
'/download/404',
{
handle: function (request, response) {
response.setStatusLine(null, 404, "Not Found");
response.write("Not found");
}
}
);
httpd.registerPathHandler(
'/download/500',
{
handle: function (request, response) {
response.setStatusLine(null, 500, "Internal Server Error");
response.write("Server error");
}
}
);
httpd.registerPathHandler(
'/download/redirect-to-file',
{
handle: function (request, response) {
response.setStatusLine(null, 302, "Found");
response.setHeader("Location", baseURL + "download/small.bin", false);
}
}
);
httpd.registerPathHandler(
'/download/custom-header',
{
handle: function (request, response) {
let val;
try {
val = request.getHeader("X-Custom");
}
catch (e) {
val = "";
}
response.setStatusLine(null, 200, "OK");
response.setHeader("X-Echo", val, false);
response.write("ok");
}
}
);
httpd.registerPathHandler(
'/download/referer',
{
handle: function (request, response) {
response.setStatusLine(null, 200, "OK");
response.setHeader(
"X-Echo",
request.hasHeader("Referer") ? request.getHeader("Referer") : "",
false
);
response.write("ok");
}
}
);
httpd.registerPathHandler(
'/download/auth',
{
handle: function (request, response) {
if (!request.hasHeader("Authorization")) {
response.setStatusLine(null, 401, "Unauthorized");
response.setHeader("WWW-Authenticate", 'Basic realm="Test"', false);
response.write("denied");
return;
}
response.setStatusLine(null, 200, "OK");
response.setHeader(
"X-Echo-Auth", request.getHeader("Authorization"), false
);
response.write("ok");
}
}
);
});
beforeEach(async function () {
Zotero.HTTP.mock = null;
tmpDir = await getTempDirectory();
});
afterEach(async function () {
await IOUtils.remove(tmpDir, { recursive: true, ignoreAbsent: true });
});
it("should download a file to disk", async function () {
let dest = PathUtils.join(tmpDir, "small.bin");
let req = await Zotero.HTTP.download(
baseURL + "download/small.bin",
dest
);
assert.equal(req.status, 200);
let stat = await IOUtils.stat(dest);
assert.equal(stat.size, 3 * 1024);
});
it("should download a larger file", async function () {
let dest = PathUtils.join(tmpDir, "large.bin");
let req = await Zotero.HTTP.download(
baseURL + "download/large.bin",
dest
);
assert.equal(req.status, 200);
let stat = await IOUtils.stat(dest);
assert.equal(stat.size, 256 * 1024);
});
it("should send request headers", async function () {
let dest = PathUtils.join(tmpDir, "custom.bin");
let req = await Zotero.HTTP.download(
baseURL + "download/custom-header",
dest,
{
headers: { "X-Custom": "test-value" }
}
);
assert.equal(req.status, 200);
assert.equal(req.headers.get("X-Echo"), "test-value");
});
it("should send a Referer header", async function () {
let dest = PathUtils.join(tmpDir, "referer.bin");
let referrer = baseURL + "article";
let req = await Zotero.HTTP.download(
baseURL + "download/referer",
dest,
{
headers: { Referer: referrer }
}
);
assert.equal(req.headers.get("X-Echo"), referrer);
});
it("should send cookies", async function () {
Services.cookies.removeAll();
let url = baseURL + "cookie-check";
// Set the cookie
await Zotero.HTTP.request('GET', url, { successCodes: false });
let dest = PathUtils.join(tmpDir, "cookie.bin");
let req = await Zotero.HTTP.download(url, dest, { successCodes: false });
assert.equal(req.status, 200);
});
it("should throw UnexpectedStatusException for non-success status", async function () {
let dest = PathUtils.join(tmpDir, "404.bin");
let e = await getPromiseError(
Zotero.HTTP.download(baseURL + "download/404", dest)
);
assert.instanceOf(e, Zotero.HTTP.UnexpectedStatusException);
assert.equal(e.status, 404);
// File should not exist
assert.isFalse(await IOUtils.exists(dest));
});
it("should allow non-success status with successCodes", async function () {
let dest = PathUtils.join(tmpDir, "404.bin");
let req = await Zotero.HTTP.download(
baseURL + "download/404",
dest,
{
successCodes: [200, 404]
}
);
assert.equal(req.status, 404);
});
it("should follow redirects", async function () {
let dest = PathUtils.join(tmpDir, "redirected.bin");
let req = await Zotero.HTTP.download(
baseURL + "download/redirect-to-file",
dest
);
assert.equal(req.status, 200);
let stat = await IOUtils.stat(dest);
assert.equal(stat.size, 3 * 1024);
});
it("should call onProgress during download", async function () {
let dest = PathUtils.join(tmpDir, "progress.bin");
let progressCalls = [];
await Zotero.HTTP.download(
baseURL + "download/large.bin",
dest,
{
onProgress(progress, progressMax) {
progressCalls.push({ progress, progressMax });
}
}
);
assert.isAbove(progressCalls.length, 0);
// Last call should have accumulated all bytes
let last = progressCalls[progressCalls.length - 1];
assert.equal(last.progress, 256 * 1024);
});
it("should retry on 5xx errors", async function () {
let delayStub = sinon.stub(Zotero.Promise, "delay")
.returns(Promise.resolve());
try {
let dest = PathUtils.join(tmpDir, "500.bin");
let e = await getPromiseError(
Zotero.HTTP.download(
baseURL + "download/500",
dest,
{
errorDelayIntervals: [10, 20],
errorDelayMax: 35
}
)
);
assert.instanceOf(e, Zotero.HTTP.UnexpectedStatusException);
assert.equal(e.status, 500);
}
finally {
delayStub.restore();
}
});
it("should accept nsIURI as first argument", async function () {
let dest = PathUtils.join(tmpDir, "nsuri.bin");
let nsUri = Services.io.newURI(baseURL + "download/small.bin");
let req = await Zotero.HTTP.download(nsUri, dest);
assert.equal(req.status, 200);
let stat = await IOUtils.stat(dest);
assert.equal(stat.size, 3 * 1024);
});
it("should decode percent-encoded credentials from URL for Basic auth", async function () {
let username = "user@example.com";
let password = "secret";
let expected = "Basic " + btoa(username + ":" + password);
let url = `http://${encodeURIComponent(username)}:${password}`
+ `@127.0.0.1:${port}/download/auth`;
let dest = PathUtils.join(tmpDir, "auth.bin");
let nsUri = Services.io.newURI(url);
let req = await Zotero.HTTP.download(nsUri, dest);
assert.equal(req.status, 200);
assert.equal(req.headers.get("X-Echo-Auth"), expected);
});
it("should encode non-ASCII credentials the same way as request()", async function () {
let url = `http://user:p%C3%A4ssw%E2%82%ACrd`
+ `@127.0.0.1:${port}/download/auth`;
let dest = PathUtils.join(tmpDir, "auth-utf8.bin");
let req = await Zotero.HTTP.download(Services.io.newURI(url), dest);
assert.equal(req.status, 200);
let xmlhttp = await Zotero.HTTP.request("GET", url);
assert.equal(
req.headers.get("X-Echo-Auth"),
xmlhttp.getResponseHeader("X-Echo-Auth")
);
});
it("should preserve literal %HH sequences in the password in request() and download()", async function () {
let username = "user";
let password = "example%41pass";
let expected = "Basic " + btoa(username + ":" + password);
let url = `http://${username}:${encodeURIComponent(password)}`
+ `@127.0.0.1:${port}/download/auth`;
let xmlhttp = await Zotero.HTTP.request("GET", url);
assert.equal(xmlhttp.getResponseHeader("X-Echo-Auth"), expected);
let dest = PathUtils.join(tmpDir, "auth-percent.bin");
let req = await Zotero.HTTP.download(Services.io.newURI(url), dest);
assert.equal(req.headers.get("X-Echo-Auth"), expected);
});
});
describe("#processDocuments()", function () {
beforeEach(function () {
Zotero.HTTP.mock = null;
});
it("should provide a document object", async function () {
var called = false;
await Zotero.HTTP.processDocuments(
testURL,
function (doc) {
assert.equal(doc.location.href, testURL);
assert.equal(doc.querySelector('p').textContent, 'Test');
var p = doc.evaluate('//p', doc, null, XPathResult.ANY_TYPE, null).iterateNext();
assert.equal(p.textContent, 'Test');
called = true;
}
);
assert.isTrue(called);
});
it("should follow meta redirect for a document", async function () {
let url1 = `http://127.0.0.1:${port}/test-redirect.html`;
let url2 = `http://127.0.0.1:${port}/test.html`;
let called = false;
await Zotero.HTTP.processDocuments(
url1,
function (doc) {
assert.equal(doc.location.href, url2);
called = true;
}
);
assert.isTrue(called);
});
});
describe("Cookie isolation", function () {
var cookieURL;
before(function () {
cookieURL = baseURL + 'cookie-check';
});
beforeEach(function () {
Zotero.HTTP.mock = null;
// Clear all cookies for the test server
Services.cookies.removeAll();
});
it("should send cookies by default (mozAnon: false)", async function () {
// First request sets the cookie but gets 403
var req = await Zotero.HTTP.request('GET', cookieURL, {
successCodes: false
});
assert.equal(req.status, 403);
// Second request should send the cookie and get 200
req = await Zotero.HTTP.request('GET', cookieURL, {
successCodes: false
});
assert.equal(req.status, 200);
});
it("should not send cookies with anon: true", async function () {
// First request sets the cookie
await Zotero.HTTP.request('GET', cookieURL, {
successCodes: false
});
// Second request with anon: true should not send the cookie
var req = await Zotero.HTTP.request('GET', cookieURL, {
anon: true,
successCodes: false
});
assert.equal(req.status, 403);
});
describe("#newCookieContext()", function () {
it("should isolate cookies from the default jar", async function () {
let ctx = Zotero.HTTP.newCookieContext();
try {
// Set cookie in the default jar
await Zotero.HTTP.request('GET', cookieURL, {
successCodes: false
});
// Request in the isolated context should not see the default cookie
let req = await Zotero.HTTP.request('GET', cookieURL, {
userContextId: ctx.id,
successCodes: false
});
assert.equal(req.status, 403);
}
finally {
ctx.dispose();
}
});
it("should persist cookies within the same context", async function () {
let ctx = Zotero.HTTP.newCookieContext();
try {
// First request in context -- gets 403, sets cookie
let req = await Zotero.HTTP.request('GET', cookieURL, {
userContextId: ctx.id,
successCodes: false
});
assert.equal(req.status, 403);
// Second request in same context -- should have the cookie
req = await Zotero.HTTP.request('GET', cookieURL, {
userContextId: ctx.id,
successCodes: false
});
assert.equal(req.status, 200);
}
finally {
ctx.dispose();
}
});
it("should not leak cookies between different contexts", async function () {
let ctx1 = Zotero.HTTP.newCookieContext();
let ctx2 = Zotero.HTTP.newCookieContext();
try {
// Set cookie in ctx1
await Zotero.HTTP.request('GET', cookieURL, {
userContextId: ctx1.id,
successCodes: false
});
// ctx2 should not see ctx1's cookie
let req = await Zotero.HTTP.request('GET', cookieURL, {
userContextId: ctx2.id,
successCodes: false
});
assert.equal(req.status, 403);
}
finally {
ctx1.dispose();
ctx2.dispose();
}
});
it("should remove cookies on dispose()", async function () {
let ctx = Zotero.HTTP.newCookieContext();
// Set cookie in context
await Zotero.HTTP.request('GET', cookieURL, {
userContextId: ctx.id,
successCodes: false
});
// Verify cookie exists
let cookies = ctx.getCookies('127.0.0.1');
assert.isTrue(cookies.some(c => c.name === 'authed'));
// Dispose and verify cookies are gone
ctx.dispose();
let ctx2 = Zotero.HTTP.newCookieContext();
try {
let req = await Zotero.HTTP.request('GET', cookieURL, {
userContextId: ctx.id,
successCodes: false
});
assert.equal(req.status, 403);
}
finally {
ctx2.dispose();
}
});
});
});
describe("CasePreservingHeaders", function () {
describe("#constructor()", function () {
it("should initialize from an iterable or object", function () {
let headers = new Zotero.HTTP.CasePreservingHeaders([['Name', 'value']]);
assert.equal(headers.get('name'), 'value');
headers = new Zotero.HTTP.CasePreservingHeaders({ NAME: 'value' });
assert.equal(headers.get('Name'), 'value');
});
});
describe("#entries()", function () {
it("should iterate through headers with original capitalization", function () {
let headers = new Zotero.HTTP.CasePreservingHeaders({ 'A-Header': 'a value' });
headers.set('FuNkY-Header', 'some other value');
assert.deepEqual(Array.from(headers.entries()), [
['A-Header', 'a value'],
['FuNkY-Header', 'some other value']
]);
});
});
});
});