From decc05d54d93ee9f83dc6a40c4b7077bfde2bf78 Mon Sep 17 00:00:00 2001 From: Geoff Lankow Date: Fri, 10 Jan 2020 12:46:24 +1300 Subject: [PATCH] Bug 1608304 part 2 - Create a mock LDAP server and some tests; r=mkmelin --HG-- extra : rebase_source : 2f9dc90f9e48b6d4c16c94e21bbec030f1c872f4 extra : histedit_source : 351a811ea0a92e6f575068847b39d3a46579f61e%2C85d9621b83f5f9dc217d572fc215e27915a9e76f --- .../addrbook/content/addressbook.js | 1 + .../addrbook/test/browser/browser.ini | 2 + .../test/browser/browser_ldap_search.js | 94 ++++++ mailnews/addrbook/test/LDAPServer.jsm | 280 ++++++++++++++++++ mailnews/addrbook/test/moz.build | 4 + .../test/unit/data/ldap_contacts.json | 104 +++++++ .../test/unit/test_ldapReplication.js | 161 ++++++++++ mailnews/addrbook/test/unit/xpcshell.ini | 2 + 8 files changed, 648 insertions(+) create mode 100644 mail/components/addrbook/test/browser/browser_ldap_search.js create mode 100644 mailnews/addrbook/test/LDAPServer.jsm create mode 100644 mailnews/addrbook/test/unit/data/ldap_contacts.json create mode 100644 mailnews/addrbook/test/unit/test_ldapReplication.js diff --git a/mail/components/addrbook/content/addressbook.js b/mail/components/addrbook/content/addressbook.js index dfef9be99f..16575930b8 100644 --- a/mail/components/addrbook/content/addressbook.js +++ b/mail/components/addrbook/content/addressbook.js @@ -142,6 +142,7 @@ var gAddressBookAbViewListener = { }, onCountChanged(total) { SetStatusText(total); + window.dispatchEvent(new CustomEvent("countchange")); }, }; diff --git a/mail/components/addrbook/test/browser/browser.ini b/mail/components/addrbook/test/browser/browser.ini index 38cf578ef1..53d2665eb2 100644 --- a/mail/components/addrbook/test/browser/browser.ini +++ b/mail/components/addrbook/test/browser/browser.ini @@ -11,4 +11,6 @@ prefs = mailnews.start_page.url=about:blank subsuite = thunderbird +[browser_ldap_search.js] +support-files = ../../../../../mailnews/addrbook/test/unit/data/ldap_contacts.json [browser_mailing_lists.js] diff --git a/mail/components/addrbook/test/browser/browser_ldap_search.js b/mail/components/addrbook/test/browser/browser_ldap_search.js new file mode 100644 index 0000000000..422c713fad --- /dev/null +++ b/mail/components/addrbook/test/browser/browser_ldap_search.js @@ -0,0 +1,94 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { LDAPServer } = ChromeUtils.import( + "resource://testing-common/LDAPServer.jsm" +); +const { mailTestUtils } = ChromeUtils.import( + "resource://testing-common/mailnews/mailTestUtils.js" +); + +const jsonFile = + "http://mochi.test:8888/browser/comm/mail/components/addrbook/test/browser/ldap_contacts.json"; + +add_task(async () => { + LDAPServer.open(); + let response = await fetch(jsonFile); + let ldapContacts = await response.json(); + + let bookPref = MailServices.ab.newAddressBook( + "Mochitest", + `ldap://localhost:${LDAPServer.port}/`, + 0 + ); + let book = MailServices.ab.getDirectoryFromId(bookPref); + + let abWindow = await openAddressBookWindow(); + let abDocument = abWindow.document; + + registerCleanupFunction(() => { + abWindow.close(); + MailServices.ab.deleteAddressBook(book.URI); + LDAPServer.close(); + }); + + let dirTree = abDocument.getElementById("dirTree"); + is(dirTree.view.getCellText(2, dirTree.columns[0]), "Mochitest"); + mailTestUtils.treeClick(EventUtils, abWindow, dirTree, 2, 0, {}); + + let resultsTree = abDocument.getElementById("abResultsTree"); + + let searchBox = abDocument.getElementById("peopleSearchInput"); + EventUtils.synthesizeMouseAtCenter(searchBox, {}, abWindow); + EventUtils.sendString("holmes", abWindow); + + await LDAPServer.read(); // BindRequest + is(resultsTree.view.rowCount, 0); + LDAPServer.writeBindResponse(); + + await LDAPServer.read(); // SearchRequest + LDAPServer.writeSearchResultEntry(ldapContacts.mycroft); + LDAPServer.writeSearchResultEntry(ldapContacts.sherlock); + LDAPServer.writeSearchResultDone(); + + await new Promise(resolve => { + abWindow.addEventListener("countchange", function onCountChange() { + if (resultsTree.view && resultsTree.view.rowCount == 2) { + abWindow.removeEventListener("countchange", onCountChange); + resolve(); + } + }); + }); + + is(resultsTree.view.rowCount, 2); + is(resultsTree.view.getCellText(0, resultsTree.columns[0]), "Mycroft Holmes"); + is( + resultsTree.view.getCellText(1, resultsTree.columns[0]), + "Sherlock Holmes" + ); + + EventUtils.synthesizeMouseAtCenter(searchBox, {}, abWindow); + EventUtils.synthesizeKey("a", { accelKey: true }, abWindow); + EventUtils.sendString("john", abWindow); + + await LDAPServer.read(); // BindRequest + is(resultsTree.view.rowCount, 0); + LDAPServer.writeBindResponse(); + + await LDAPServer.read(); // SearchRequest + LDAPServer.writeSearchResultEntry(ldapContacts.john); + LDAPServer.writeSearchResultDone(); + + await new Promise(resolve => { + abWindow.addEventListener("countchange", function onCountChange() { + if (resultsTree.view && resultsTree.view.rowCount == 1) { + abWindow.removeEventListener("countchange", onCountChange); + resolve(); + } + }); + }); + + is(resultsTree.view.rowCount, 1); + is(resultsTree.view.getCellText(0, resultsTree.columns[0]), "John Watson"); +}); diff --git a/mailnews/addrbook/test/LDAPServer.jsm b/mailnews/addrbook/test/LDAPServer.jsm new file mode 100644 index 0000000000..066ba8ae3e --- /dev/null +++ b/mailnews/addrbook/test/LDAPServer.jsm @@ -0,0 +1,280 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +const EXPORTED_SYMBOLS = ["LDAPServer"]; +const PRINT_DEBUG = false; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +/** + * This is a partial implementation of an LDAP server as defined by RFC 4511. + * It's not intended to serve any particular dataset, rather, tests should + * cause the application to make requests and tell the server what to respond. + * + * https://docs.ldap.com/specs/rfc4511.txt + * + * @implements nsIInputStreamCallback + * @implements nsIServerSocketListener + */ +var LDAPServer = { + serverSocket: null, + + QueryInterface: ChromeUtils.generateQI([ + Ci.nsIInputStreamCallback, + Ci.nsIServerSocketListener, + ]), + + /** + * Start listening on an OS-selected port. The port number can be found at + * LDAPServer.port. + */ + open() { + this.serverSocket = Cc[ + "@mozilla.org/network/server-socket;1" + ].createInstance(Ci.nsIServerSocket); + this.serverSocket.init(-1, true, 1); + console.log(`socket open on port ${this.serverSocket.port}`); + + this.serverSocket.asyncListen(this); + }, + /** + * Stop listening for new connections and close any that are open. + */ + close() { + this.serverSocket.close(); + }, + /** + * The port this server is listening on. + */ + get port() { + return this.serverSocket.port; + }, + + /** + * Retrieves any data sent to the server since connection or the previous + * call to read(). This should be called every time the application is + * expected to send data. + * + * @returns {Promise} Resolves when data is received by the server, with the + * data as a byte array. + */ + read() { + return new Promise(resolve => { + if (this._data) { + resolve(this._data); + delete this._data; + } + this._inputStreamReadyResolve = resolve; + }); + }, + /** + * Sends raw data to the application. Generally this shouldn't be used + * directly but it may be useful for testing. + * + * @param {byte array} Data + */ + write(data) { + if (PRINT_DEBUG) { + console.log( + ">>> " + data.map(b => b.toString(16).padStart(2, 0)).join(" ") + ); + } + this._outputStream.writeByteArray(data); + }, + /** + * Sends a simple BindResponse to the application. + * See section 4.2.2 of the RFC. + */ + writeBindResponse() { + let message = new Sequence(0x30, new IntegerValue(this._lastMessageID)); + let person = new Sequence( + 0x61, + new EnumeratedValue(0), + new StringValue(""), + new StringValue("") + ); + message.children.push(person); + this.write(message.getBytes()); + }, + /** + * Sends a SearchResultEntry to the application. + * See section 4.5.2 of the RFC. + * + * @param {object} An object representing a person. Keys of the object are: + * - dn The LDAP DN of the person + * - attributes A key/value or key/array-of-values object + * representing the person + */ + writeSearchResultEntry({ dn, attributes }) { + let message = new Sequence(0x30, new IntegerValue(this._lastMessageID)); + + let person = new Sequence(0x64, new StringValue(dn)); + message.children.push(person); + + let attributeSequence = new Sequence(0x30); + person.children.push(attributeSequence); + + for (let [key, value] of Object.entries(attributes)) { + let seq = new Sequence(0x30, new StringValue(key), new Sequence(0x31)); + if (typeof value == "string") { + value = [value]; + } + for (let v of value) { + seq.children[1].children.push(new StringValue(v)); + } + attributeSequence.children.push(seq); + } + + this.write(message.getBytes()); + }, + /** + * Sends a SearchResultDone to the application. + * See section 4.5.2 of the RFC. + */ + writeSearchResultDone() { + let message = new Sequence(0x30, new IntegerValue(this._lastMessageID)); + let person = new Sequence( + 0x65, + new EnumeratedValue(0), + new StringValue(""), + new StringValue("") + ); + message.children.push(person); + this.write(message.getBytes()); + }, + + /** + * nsIServerSocketListener.onSocketAccepted + */ + onSocketAccepted(socket, transport) { + let inputStream = transport + .openInputStream(0, 8192, 1024) + .QueryInterface(Ci.nsIAsyncInputStream); + + let outputStream = transport.openOutputStream(0, 0, 0); + this._outputStream = Cc["@mozilla.org/binaryoutputstream;1"].createInstance( + Ci.nsIBinaryOutputStream + ); + this._outputStream.setOutputStream(outputStream); + + if (this._socketConnectedResolve) { + this._socketConnectedResolve(); + delete this._socketConnectedResolve; + } + inputStream.asyncWait(this, 0, 0, Services.tm.mainThread); + }, + /** + * nsIServerSocketListener.onStopListening + */ + onStopListening(socket, status) { + console.log(`socket closed with status ${status.toString(16)}`); + }, + + /** + * nsIInputStreamCallback.onInputStreamReady + */ + onInputStreamReady(stream) { + let available; + try { + available = stream.available(); + } catch (ex) { + if ( + [Cr.NS_BASE_STREAM_CLOSED, Cr.NS_ERROR_NET_RESET].includes(ex.result) + ) { + return; + } + throw ex; + } + + let binaryInputStream = Cc[ + "@mozilla.org/binaryinputstream;1" + ].createInstance(Ci.nsIBinaryInputStream); + binaryInputStream.setInputStream(stream); + let data = binaryInputStream.readByteArray(available); + if (PRINT_DEBUG) { + console.log( + "<<< " + data.map(b => b.toString(16).padStart(2, 0)).join(" ") + ); + } + this._lastMessageID = data[4]; + + if (this._inputStreamReadyResolve) { + this._inputStreamReadyResolve(data); + delete this._inputStreamReadyResolve; + } else { + this._data = data; + } + + stream.asyncWait(this, 0, 0, Services.tm.mainThread); + }, +}; + +/** + * Helper classes to convert primitives to LDAP byte sequences. + */ + +class Sequence { + constructor(number, ...children) { + this.number = number; + this.children = children; + } + getBytes() { + let bytes = []; + for (let c of this.children) { + bytes = bytes.concat(c.getBytes()); + } + return [this.number].concat(getLengthBytes(bytes.length), bytes); + } +} +class IntegerValue { + constructor(int) { + this.int = int; + this.number = 0x02; + } + getBytes() { + let temp = this.int; + let bytes = []; + + while (temp >= 128) { + bytes.unshift(temp & 255); + temp >>= 8; + } + bytes.unshift(temp); + return [this.number].concat(getLengthBytes(bytes.length), bytes); + } +} +class StringValue { + constructor(str) { + this.str = str; + } + getBytes() { + return [0x04].concat( + getLengthBytes(this.str.length), + Array.from(this.str, c => c.charCodeAt(0)) + ); + } +} +class EnumeratedValue extends IntegerValue { + constructor(int) { + super(int); + this.number = 0x0a; + } +} + +function getLengthBytes(int) { + if (int < 128) { + return [int]; + } + + let temp = int; + let bytes = []; + + while (temp >= 128) { + bytes.unshift(temp & 255); + temp >>= 8; + } + bytes.unshift(temp); + bytes.unshift(0x80 | bytes.length); + return bytes; +} diff --git a/mailnews/addrbook/test/moz.build b/mailnews/addrbook/test/moz.build index 71ae38d493..0cf61935d2 100644 --- a/mailnews/addrbook/test/moz.build +++ b/mailnews/addrbook/test/moz.build @@ -3,6 +3,10 @@ # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. +TESTING_JS_MODULES += [ + 'LDAPServer.jsm', +] + XPCSHELL_TESTS_MANIFESTS += [ 'unit/xpcshell.ini', 'unit/xpcshell_migration.ini', diff --git a/mailnews/addrbook/test/unit/data/ldap_contacts.json b/mailnews/addrbook/test/unit/data/ldap_contacts.json new file mode 100644 index 0000000000..c239820d51 --- /dev/null +++ b/mailnews/addrbook/test/unit/data/ldap_contacts.json @@ -0,0 +1,104 @@ +{ + "eurus": { + "dn": "uid=eurus,dc=bakerstreet,dc=invalid", + "attributes": { + "objectClass": "person", + "cn": "Eurus Holmes", + "givenName": "Eurus", + "mail": "eurus@bakerstreet.invalid", + "sn": "Holmes" + } + }, + "irene": { + "dn": "uid=irene,dc=bakerstreet,dc=invalid", + "attributes": { + "objectClass": "person", + "cn": "Irene Adler", + "givenName": "irene", + "mail": "irene@bakerstreet.invalid", + "sn": "Adler" + } + }, + "john": { + "dn": "uid=john,dc=bakerstreet,dc=invalid", + "attributes": { + "objectClass": "person", + "cn": "John Watson", + "givenName": "John", + "mail": "john@bakerstreet.invalid", + "sn": "Watson" + } + }, + "lestrade": { + "dn": "uid=lestrade,dc=bakerstreet,dc=invalid", + "attributes": { + "objectClass": "person", + "cn": "Greg Lestrade", + "givenName": "Greg", + "mail": "lestrade@bakerstreet.invalid", + "o": "New Scotland Yard", + "sn": "Lestrade" + } + }, + "mary": { + "dn": "uid=mary,dc=bakerstreet,dc=invalid", + "attributes": { + "objectClass": "person", + "cn": "Mary Watson", + "givenName": "Mary", + "mail": "mary@bakerstreet.invalid", + "sn": "Watson" + } + }, + "molly": { + "dn": "uid=molly,dc=bakerstreet,dc=invalid", + "attributes": { + "objectClass": "person", + "cn": "Molly Hooper", + "givenName": "Molly", + "mail": "molly@bakerstreet.invalid", + "o": "St. Bartholomew's Hospital", + "sn": "Hooper" + } + }, + "moriarty": { + "dn": "uid=moriarty,dc=bakerstreet,dc=invalid", + "attributes": { + "objectClass": "person", + "cn": "Jim Moriarty", + "givenName": "Jim", + "mail": "moriarty@bakerstreet.invalid", + "sn": "Moriarty" + } + }, + "mrs_hudson": { + "dn": "uid=mrs_hudson,dc=bakerstreet,dc=invalid", + "attributes": { + "objectClass": "person", + "cn": "Mrs Hudson", + "givenName": "Martha", + "mail": "mrs_hudson@bakerstreet.invalid", + "sn": "Hudson" + } + }, + "mycroft": { + "dn": "uid=mycroft,dc=bakerstreet,dc=invalid", + "attributes": { + "objectClass": "person", + "cn": "Mycroft Holmes", + "givenName": "Mycroft", + "mail": "mycroft@bakerstreet.invalid", + "sn": "Holmes" + } + }, + "sherlock": { + "dn": "uid=sherlock,dc=bakerstreet,dc=invalid", + "attributes": { + "objectClass": "person", + "cn": "Sherlock Holmes", + "givenName": "Sherlock", + "mail": "sherlock@bakerstreet.invalid", + "sn": "Holmes" + } + } +} diff --git a/mailnews/addrbook/test/unit/test_ldapReplication.js b/mailnews/addrbook/test/unit/test_ldapReplication.js new file mode 100644 index 0000000000..f4663da004 --- /dev/null +++ b/mailnews/addrbook/test/unit/test_ldapReplication.js @@ -0,0 +1,161 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { LDAPServer } = ChromeUtils.import( + "resource://testing-common/LDAPServer.jsm" +); +const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm"); + +const autocompleteService = Cc[ + "@mozilla.org/autocomplete/search;1?name=addrbook" +].getService(Ci.nsIAutoCompleteSearch); +const jsonFile = do_get_file("data/ldap_contacts.json"); +const replicationService = Cc[ + "@mozilla.org/addressbook/ldap-replication-service;1" +].getService(Ci.nsIAbLDAPReplicationService); + +add_task(async () => { + LDAPServer.open(); + let contents = await OS.File.read(jsonFile.path); + let ldapContacts = await JSON.parse(new TextDecoder().decode(contents)); + + let bookPref = MailServices.ab.newAddressBook( + "XPCShell", + `ldap://localhost:${LDAPServer.port}/people??sub?(objectclass=*)`, + 0 + ); + let book = MailServices.ab.getDirectoryFromId(bookPref); + book.QueryInterface(Ci.nsIAbLDAPDirectory); + equal(book.replicationFileName, "ldap.sqlite"); + + Services.prefs.setCharPref("ldap_2.autoComplete.directoryServer", bookPref); + Services.prefs.setBoolPref("ldap_2.autoComplete.useDirectory", true); + + registerCleanupFunction(async () => { + LDAPServer.close(); + }); + + let progressResolve; + let progressPromise = new Promise(resolve => (progressResolve = resolve)); + let progressListener = { + onStateChange(webProgress, request, stateFlags, status) { + if (stateFlags & Ci.nsIWebProgressListener.STATE_START) { + info("replication started"); + } + if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) { + info("replication ended"); + progressResolve(); + } + }, + onProgressChange( + webProgress, + request, + currentSelfProgress, + maxSelfProgress, + currentTotalProgress, + maxTotalProgress + ) {}, + onLocationChange(webProgress, request, location, flags) {}, + onStatusChange(webProgress, request, status, message) {}, + onSecurityChange(webProgress, request, state) {}, + onContentBlockingEvent(webProgress, request, event) {}, + }; + + replicationService.startReplication(book, progressListener); + + await LDAPServer.read(); // BindRequest + LDAPServer.writeBindResponse(); + + await LDAPServer.read(); // SearchRequest + for (let contact of Object.values(ldapContacts)) { + LDAPServer.writeSearchResultEntry(contact); + } + LDAPServer.writeSearchResultDone(); + + await progressPromise; + equal(book.replicationFileName, "ldap.sqlite"); + + Services.io.offline = true; + + let cards = [...book.childCards]; + deepEqual(cards.map(c => c.displayName).sort(), [ + "Eurus Holmes", + "Greg Lestrade", + "Irene Adler", + "Jim Moriarty", + "John Watson", + "Mary Watson", + "Molly Hooper", + "Mrs Hudson", + "Mycroft Holmes", + "Sherlock Holmes", + ]); + + await new Promise(resolve => { + autocompleteService.startSearch("molly", '{"type":"addr_to"}', null, { + onSearchResult(search, result) { + equal(result.matchCount, 1); + equal(result.getValueAt(0), "Molly Hooper "); + resolve(); + }, + }); + }); + await new Promise(resolve => { + autocompleteService.startSearch("watson", '{"type":"addr_to"}', null, { + onSearchResult(search, result) { + equal(result.matchCount, 2); + equal(result.getValueAt(0), "John Watson "); + equal(result.getValueAt(1), "Mary Watson "); + resolve(); + }, + }); + }); + + // Do it again with different information from the server. Ensure we have the new information. + + progressPromise = new Promise(resolve => (progressResolve = resolve)); + replicationService.startReplication(book, progressListener); + + await LDAPServer.read(); // BindRequest + LDAPServer.writeBindResponse(); + + await LDAPServer.read(); // SearchRequest + LDAPServer.writeSearchResultEntry(ldapContacts.eurus); + LDAPServer.writeSearchResultEntry(ldapContacts.mary); + LDAPServer.writeSearchResultEntry(ldapContacts.molly); + LDAPServer.writeSearchResultDone(); + + await progressPromise; + equal(book.replicationFileName, "ldap.sqlite"); + + cards = [...book.childCards]; + deepEqual(cards.map(c => c.displayName).sort(), [ + "Eurus Holmes", + "Mary Watson", + "Molly Hooper", + ]); + + // Do it again but cancel. Ensure we still have the old information. + + progressPromise = new Promise(resolve => (progressResolve = resolve)); + replicationService.startReplication(book, progressListener); + + await LDAPServer.read(); // BindRequest + LDAPServer.writeBindResponse(); + + await LDAPServer.read(); // SearchRequest + LDAPServer.writeSearchResultEntry(ldapContacts.john); + LDAPServer.writeSearchResultEntry(ldapContacts.sherlock); + LDAPServer.writeSearchResultEntry(ldapContacts.mrs_hudson); + replicationService.cancelReplication(book); + + await progressPromise; + + cards = [...book.childCards]; + deepEqual(cards.map(c => c.displayName).sort(), [ + "Eurus Holmes", + "Mary Watson", + "Molly Hooper", + ]); +}); diff --git a/mailnews/addrbook/test/unit/xpcshell.ini b/mailnews/addrbook/test/unit/xpcshell.ini index 70cc042684..e7c4817481 100644 --- a/mailnews/addrbook/test/unit/xpcshell.ini +++ b/mailnews/addrbook/test/unit/xpcshell.ini @@ -17,6 +17,8 @@ support-files = data/* [test_ldap1.js] [test_ldap2.js] [test_ldapOffline.js] +[test_ldapReplication.js] +skip-if = debug # Fails for unknown reasons. [test_mailList1.js] [test_notifications.js] [test_nsAbAutoCompleteMyDomain.js]