diff options
Diffstat (limited to 'common')
33 files changed, 11337 insertions, 0 deletions
| diff --git a/common/changes/feature_3487-split-soledad-into-common-client-and-server b/common/changes/feature_3487-split-soledad-into-common-client-and-server new file mode 100644 index 00000000..2eab6b56 --- /dev/null +++ b/common/changes/feature_3487-split-soledad-into-common-client-and-server @@ -0,0 +1 @@ +  o Split soledad package into common, client and server. Closes #3487. diff --git a/common/setup.py b/common/setup.py new file mode 100644 index 00000000..6a432608 --- /dev/null +++ b/common/setup.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +# setup.py +# Copyright (C) 2013 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +from setuptools import ( +    setup, +    find_packages +) + + +install_requirements = [ +    'simplejson', +    'oauth',  # this is not strictly needed by us, but we need it +              # until u1db adds it to its release as a dep. +    'u1db', +    'six==1.1.0',  # some tests are incompatible with newer versions of six. +] + + +tests_requirements = [ +    'mock', +    'nose2', +    'testscenarios', +    'leap.common', +    'leap.soledad.server', +    'leap.soledad.client', +] + + +trove_classifiers = ( +    "Development Status :: 3 - Alpha", +    "Intended Audience :: Developers", +    "License :: OSI Approved :: " +    "GNU General Public License v3 or later (GPLv3+)", +    "Environment :: Console", +    "Operating System :: OS Independent", +    "Operating System :: POSIX", +    "Programming Language :: Python :: 2.6", +    "Programming Language :: Python :: 2.7", +    "Topic :: Database :: Front-Ends", +    "Topic :: Software Development :: Libraries :: Python Modules" +) + + +setup( +    name='leap.soledad.common', +    version='0.3.0', +    url='https://leap.se/', +    license='GPLv3+', +    description='Synchronization of locally encrypted data among devices.', +    author='The LEAP Encryption Access Project', +    author_email='info@leap.se', +    long_description=( +        "Soledad is the part of LEAP that allows application data to be " +        "securely shared among devices. It provides, to other parts of the " +        "LEAP client, an API for data storage and sync." +    ), +    namespace_packages=["leap", "leap.soledad"], +    packages=find_packages('src', exclude=['leap.soledad.common.tests']), +    package_dir={'': 'src'}, +    test_suite='leap.soledad.common.tests', +    install_requires=install_requirements, +    tests_require=tests_requirements, +    classifiers=trove_classifiers, +) diff --git a/common/src/leap/__init__.py b/common/src/leap/__init__.py new file mode 100644 index 00000000..f48ad105 --- /dev/null +++ b/common/src/leap/__init__.py @@ -0,0 +1,6 @@ +# See http://peak.telecommunity.com/DevCenter/setuptools#namespace-packages +try: +    __import__('pkg_resources').declare_namespace(__name__) +except ImportError: +    from pkgutil import extend_path +    __path__ = extend_path(__path__, __name__) diff --git a/common/src/leap/soledad/__init__.py b/common/src/leap/soledad/__init__.py new file mode 100644 index 00000000..f48ad105 --- /dev/null +++ b/common/src/leap/soledad/__init__.py @@ -0,0 +1,6 @@ +# See http://peak.telecommunity.com/DevCenter/setuptools#namespace-packages +try: +    __import__('pkg_resources').declare_namespace(__name__) +except ImportError: +    from pkgutil import extend_path +    __path__ = extend_path(__path__, __name__) diff --git a/common/src/leap/soledad/common/__init__.py b/common/src/leap/soledad/common/__init__.py new file mode 100644 index 00000000..4e45b828 --- /dev/null +++ b/common/src/leap/soledad/common/__init__.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +# __init__.py +# Copyright (C) 2013 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +""" +Soledad routines common to client and server. +""" + + +# +# Assert functions +# + +# we want to use leap.common.check.leap_assert in case it is available, +# because it also logs in a way other parts of leap can access log messages. + +try: +    from leap.common.check import leap_assert as soledad_assert + +except ImportError: + +    def soledad_assert(condition, message): +        """ +        Asserts the condition and displays the message if that's not +        met. + +        @param condition: condition to check +        @type condition: bool +        @param message: message to display if the condition isn't met +        @type message: str +        """ +        assert condition, message + +try: +    from leap.common.check import leap_assert_type as soledad_assert_type + +except ImportError: + +    def soledad_assert_type(var, expectedType): +        """ +        Helper assert check for a variable's expected type + +        @param var: variable to check +        @type var: any +        @param expectedType: type to check agains +        @type expectedType: type +        """ +        soledad_assert(isinstance(var, expectedType), +                       "Expected type %r instead of %r" % +                       (expectedType, type(var))) diff --git a/common/src/leap/soledad/common/couch.py b/common/src/leap/soledad/common/couch.py new file mode 100644 index 00000000..9642e8f3 --- /dev/null +++ b/common/src/leap/soledad/common/couch.py @@ -0,0 +1,479 @@ +# -*- coding: utf-8 -*- +# couch.py +# Copyright (C) 2013 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +"""A U1DB backend that uses CouchDB as its persistence layer.""" + +import uuid +import re +import simplejson as json + + +from base64 import b64encode, b64decode +from u1db import errors +from u1db.sync import Synchronizer +from u1db.backends.inmemory import InMemoryIndex +from u1db.remote.server_state import ServerState +from u1db.errors import DatabaseDoesNotExist +from couchdb.client import Server, Document as CouchDocument +from couchdb.http import ResourceNotFound + + +from leap.soledad.common.objectstore import ( +    ObjectStoreDatabase, +    ObjectStoreSyncTarget, +) + + +class InvalidURLError(Exception): +    """ +    Exception raised when Soledad encounters a malformed URL. +    """ + + +class CouchDatabase(ObjectStoreDatabase): +    """ +    A U1DB backend that uses Couch as its persistence layer. +    """ + +    U1DB_TRANSACTION_LOG_KEY = 'transaction_log' +    U1DB_CONFLICTS_KEY = 'conflicts' +    U1DB_OTHER_GENERATIONS_KEY = 'other_generations' +    U1DB_INDEXES_KEY = 'indexes' +    U1DB_REPLICA_UID_KEY = 'replica_uid' + +    COUCH_ID_KEY = '_id' +    COUCH_REV_KEY = '_rev' +    COUCH_U1DB_ATTACHMENT_KEY = 'u1db_json' +    COUCH_U1DB_REV_KEY = 'u1db_rev' + +    @classmethod +    def open_database(cls, url, create): +        """ +        Open a U1DB database using CouchDB as backend. + +        @param url: the url of the database replica +        @type url: str +        @param create: should the replica be created if it does not exist? +        @type create: bool + +        @return: the database instance +        @rtype: CouchDatabase +        """ +        # get database from url +        m = re.match('(^https?://[^/]+)/(.+)$', url) +        if not m: +            raise InvalidURLError +        url = m.group(1) +        dbname = m.group(2) +        server = Server(url=url) +        try: +            server[dbname] +        except ResourceNotFound: +            if not create: +                raise DatabaseDoesNotExist() +        return cls(url, dbname) + +    def __init__(self, url, dbname, replica_uid=None, full_commit=True, +                 session=None): +        """ +        Create a new Couch data container. + +        @param url: the url of the couch database +        @type url: str +        @param dbname: the database name +        @type dbname: str +        @param replica_uid: an optional unique replica identifier +        @type replica_uid: str +        @param full_commit: turn on the X-Couch-Full-Commit header +        @type full_commit: bool +        @param session: an http.Session instance or None for a default session +        @type session: http.Session +        """ +        self._url = url +        self._full_commit = full_commit +        self._session = session +        self._server = Server(url=self._url, +                              full_commit=self._full_commit, +                              session=self._session) +        self._dbname = dbname +        # this will ensure that transaction and sync logs exist and are +        # up-to-date. +        try: +            self._database = self._server[self._dbname] +        except ResourceNotFound: +            self._server.create(self._dbname) +            self._database = self._server[self._dbname] +        ObjectStoreDatabase.__init__(self, replica_uid=replica_uid) + +    #------------------------------------------------------------------------- +    # methods from Database +    #------------------------------------------------------------------------- + +    def _get_doc(self, doc_id, check_for_conflicts=False): +        """ +        Get just the document content, without fancy handling. + +        @param doc_id: The unique document identifier +        @type doc_id: str +        @param include_deleted: If set to True, deleted documents will be +            returned with empty content. Otherwise asking for a deleted +            document will return None. +        @type include_deleted: bool + +        @return: a Document object. +        @type: u1db.Document +        """ +        cdoc = self._database.get(doc_id) +        if cdoc is None: +            return None +        has_conflicts = False +        if check_for_conflicts: +            has_conflicts = self._has_conflicts(doc_id) +        doc = self._factory( +            doc_id=doc_id, +            rev=cdoc[self.COUCH_U1DB_REV_KEY], +            has_conflicts=has_conflicts) +        contents = self._database.get_attachment( +            cdoc, +            self.COUCH_U1DB_ATTACHMENT_KEY) +        if contents: +            doc.content = json.loads(contents.read()) +        else: +            doc.make_tombstone() +        return doc + +    def get_all_docs(self, include_deleted=False): +        """ +        Get the JSON content for all documents in the database. + +        @param include_deleted: If set to True, deleted documents will be +            returned with empty content. Otherwise deleted documents will not +            be included in the results. +        @type include_deleted: bool + +        @return: (generation, [Document]) +            The current generation of the database, followed by a list of all +            the documents in the database. +        @rtype: tuple +        """ +        generation = self._get_generation() +        results = [] +        for doc_id in self._database: +            if doc_id == self.U1DB_DATA_DOC_ID: +                continue +            doc = self._get_doc(doc_id, check_for_conflicts=True) +            if doc.content is None and not include_deleted: +                continue +            results.append(doc) +        return (generation, results) + +    def _put_doc(self, doc): +        """ +        Update a document. + +        This is called everytime we just want to do a raw put on the db (i.e. +        without index updates, document constraint checks, and conflict +        checks). + +        @param doc: The document to update. +        @type doc: u1db.Document + +        @return: The new revision identifier for the document. +        @rtype: str +        """ +        # prepare couch's Document +        cdoc = CouchDocument() +        cdoc[self.COUCH_ID_KEY] = doc.doc_id +        # we have to guarantee that couch's _rev is consistent +        old_cdoc = self._database.get(doc.doc_id) +        if old_cdoc is not None: +            cdoc[self.COUCH_REV_KEY] = old_cdoc[self.COUCH_REV_KEY] +        # store u1db's rev +        cdoc[self.COUCH_U1DB_REV_KEY] = doc.rev +        # save doc in db +        self._database.save(cdoc) +        # store u1db's content as json string +        if not doc.is_tombstone(): +            self._database.put_attachment( +                cdoc, doc.get_json(), +                filename=self.COUCH_U1DB_ATTACHMENT_KEY) +        else: +            self._database.delete_attachment( +                cdoc, +                self.COUCH_U1DB_ATTACHMENT_KEY) + +    def get_sync_target(self): +        """ +        Return a SyncTarget object, for another u1db to synchronize with. + +        @return: The sync target. +        @rtype: CouchSyncTarget +        """ +        return CouchSyncTarget(self) + +    def create_index(self, index_name, *index_expressions): +        """ +        Create a named index, which can then be queried for future lookups. + +        @param index_name: A unique name which can be used as a key prefix. +        @param index_expressions: Index expressions defining the index +            information. +        """ +        if index_name in self._indexes: +            if self._indexes[index_name]._definition == list( +                    index_expressions): +                return +            raise errors.IndexNameTakenError +        index = InMemoryIndex(index_name, list(index_expressions)) +        for doc_id in self._database: +            if doc_id == self.U1DB_DATA_DOC_ID:  # skip special file +                continue +            doc = self._get_doc(doc_id) +            if doc.content is not None: +                index.add_json(doc_id, doc.get_json()) +        self._indexes[index_name] = index +        # save data in object store +        self._store_u1db_data() + +    def close(self): +        """ +        Release any resources associated with this database. + +        @return: True if db was succesfully closed. +        @rtype: bool +        """ +        # TODO: fix this method so the connection is properly closed and +        # test_close (+tearDown, which deletes the db) works without problems. +        self._url = None +        self._full_commit = None +        self._session = None +        #self._server = None +        self._database = None +        return True + +    def sync(self, url, creds=None, autocreate=True): +        """ +        Synchronize documents with remote replica exposed at url. + +        @param url: The url of the target replica to sync with. +        @type url: str +        @param creds: optional dictionary giving credentials. +            to authorize the operation with the server. +        @type creds: dict +        @param autocreate: Ask the target to create the db if non-existent. +        @type autocreate: bool + +        @return: The local generation before the synchronisation was performed. +        @rtype: int +        """ +        return Synchronizer(self, CouchSyncTarget(url, creds=creds)).sync( +            autocreate=autocreate) + +    #------------------------------------------------------------------------- +    # methods from ObjectStoreDatabase +    #------------------------------------------------------------------------- + +    def _init_u1db_data(self): +        """ +        Initialize U1DB info data structure in the couch db. + +        A U1DB database needs to keep track of all database transactions, +        document conflicts, the generation of other replicas it has seen, +        indexes created by users and so on. + +        In this implementation, all this information is stored in a special +        document stored in the couch db with id equals to +        CouchDatabse.U1DB_DATA_DOC_ID. + +        This method initializes the document that will hold such information. +        """ +        if self._replica_uid is None: +            self._replica_uid = uuid.uuid4().hex +        # TODO: prevent user from overwriting a document with the same doc_id +        # as this one. +        doc = self._factory(doc_id=self.U1DB_DATA_DOC_ID) +        doc.content = { +            self.U1DB_TRANSACTION_LOG_KEY: b64encode(json.dumps([])), +            self.U1DB_CONFLICTS_KEY: b64encode(json.dumps({})), +            self.U1DB_OTHER_GENERATIONS_KEY: b64encode(json.dumps({})), +            self.U1DB_INDEXES_KEY: b64encode(json.dumps({})), +            self.U1DB_REPLICA_UID_KEY: b64encode(self._replica_uid), +        } +        self._put_doc(doc) + +    def _fetch_u1db_data(self): +        """ +        Fetch U1DB info from the couch db. + +        See C{_init_u1db_data} documentation. +        """ +        # retrieve u1db data from couch db +        cdoc = self._database.get(self.U1DB_DATA_DOC_ID) +        jsonstr = self._database.get_attachment( +            cdoc, self.COUCH_U1DB_ATTACHMENT_KEY).read() +        content = json.loads(jsonstr) +        # set u1db database info +        self._transaction_log = json.loads( +            b64decode(content[self.U1DB_TRANSACTION_LOG_KEY])) +        self._conflicts = json.loads( +            b64decode(content[self.U1DB_CONFLICTS_KEY])) +        self._other_generations = json.loads( +            b64decode(content[self.U1DB_OTHER_GENERATIONS_KEY])) +        self._indexes = self._load_indexes_from_json( +            b64decode(content[self.U1DB_INDEXES_KEY])) +        self._replica_uid = b64decode(content[self.U1DB_REPLICA_UID_KEY]) +        # save couch _rev +        self._couch_rev = cdoc[self.COUCH_REV_KEY] + +    def _store_u1db_data(self): +        """ +        Store U1DB info in the couch db. + +        See C{_init_u1db_data} documentation. +        """ +        doc = self._factory(doc_id=self.U1DB_DATA_DOC_ID) +        doc.content = { +            # Here, the b64 encode ensures that document content +            # does not cause strange behaviour in couchdb because +            # of encoding. +            self.U1DB_TRANSACTION_LOG_KEY: +                b64encode(json.dumps(self._transaction_log)), +            self.U1DB_CONFLICTS_KEY: b64encode(json.dumps(self._conflicts)), +            self.U1DB_OTHER_GENERATIONS_KEY: +                b64encode(json.dumps(self._other_generations)), +            self.U1DB_INDEXES_KEY: b64encode(self._dump_indexes_as_json()), +            self.U1DB_REPLICA_UID_KEY: b64encode(self._replica_uid), +            self.COUCH_REV_KEY: self._couch_rev} +        self._put_doc(doc) + +    #------------------------------------------------------------------------- +    # Couch specific methods +    #------------------------------------------------------------------------- + +    INDEX_NAME_KEY = 'name' +    INDEX_DEFINITION_KEY = 'definition' +    INDEX_VALUES_KEY = 'values' + +    def delete_database(self): +        """ +        Delete a U1DB CouchDB database. +        """ +        del(self._server[self._dbname]) + +    def _dump_indexes_as_json(self): +        """ +        Dump index definitions as JSON string. +        """ +        indexes = {} +        for name, idx in self._indexes.iteritems(): +            indexes[name] = {} +            for attr in [self.INDEX_NAME_KEY, self.INDEX_DEFINITION_KEY, +                         self.INDEX_VALUES_KEY]: +                indexes[name][attr] = getattr(idx, '_' + attr) +        return json.dumps(indexes) + +    def _load_indexes_from_json(self, indexes): +        """ +        Load index definitions from JSON string. + +        @param indexes: A JSON serialization of a list of [('index-name', +            ['field', 'field2'])]. +        @type indexes: str + +        @return: A dictionary with the index definitions. +        @rtype: dict +        """ +        dict = {} +        for name, idx_dict in json.loads(indexes).iteritems(): +            idx = InMemoryIndex(name, idx_dict[self.INDEX_DEFINITION_KEY]) +            idx._values = idx_dict[self.INDEX_VALUES_KEY] +            dict[name] = idx +        return dict + + +class CouchSyncTarget(ObjectStoreSyncTarget): +    """ +    Functionality for using a CouchDatabase as a synchronization target. +    """ + + +class CouchServerState(ServerState): +    """ +    Inteface of the WSGI server with the CouchDB backend. +    """ + +    def __init__(self, couch_url): +        self._couch_url = couch_url + +    def open_database(self, dbname): +        """ +        Open a couch database. + +        @param dbname: The name of the database to open. +        @type dbname: str + +        @return: The CouchDatabase object. +        @rtype: CouchDatabase +        """ +        # TODO: open couch +        return CouchDatabase.open_database( +            self._couch_url + '/' + dbname, +            create=False) + +    def ensure_database(self, dbname): +        """ +        Ensure couch database exists. + +        @param dbname: The name of the database to ensure. +        @type dbname: str + +        @return: The CouchDatabase object and the replica uid. +        @rtype: (CouchDatabase, str) +        """ +        db = CouchDatabase.open_database( +            self._couch_url + '/' + dbname, +            create=True) +        return db, db._replica_uid + +    def delete_database(self, dbname): +        """ +        Delete couch database. + +        @param dbname: The name of the database to delete. +        @type dbname: str +        """ +        CouchDatabase.delete_database(self._couch_url + '/' + dbname) + +    def _set_couch_url(self, url): +        """ +        Set the couchdb URL + +        @param url: CouchDB URL +        @type url: str +        """ +        self._couch_url = url + +    def _get_couch_url(self): +        """ +        Return CouchDB URL + +        @rtype: str +        """ +        return self._couch_url + +    couch_url = property(_get_couch_url, _set_couch_url, doc='CouchDB URL') diff --git a/common/src/leap/soledad/common/crypto.py b/common/src/leap/soledad/common/crypto.py new file mode 100644 index 00000000..2c6bd7a3 --- /dev/null +++ b/common/src/leap/soledad/common/crypto.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# crypto.py +# Copyright (C) 2013 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +""" +Soledad common crypto bits. +""" + + +# +# Encryption schemes used for encryption. +# + +class EncryptionSchemes(object): +    """ +    Representation of encryption schemes used to encrypt documents. +    """ + +    NONE = 'none' +    SYMKEY = 'symkey' +    PUBKEY = 'pubkey' + + +class MacMethods(object): +    """ +    Representation of MAC methods used to authenticate document's contents. +    """ + +    HMAC = 'hmac' + + +# +# Crypto utilities for a SoledadDocument. +# + +ENC_JSON_KEY = '_enc_json' +ENC_SCHEME_KEY = '_enc_scheme' +ENC_METHOD_KEY = '_enc_method' +ENC_IV_KEY = '_enc_iv' +MAC_KEY = '_mac' +MAC_METHOD_KEY = '_mac_method' diff --git a/common/src/leap/soledad/common/document.py b/common/src/leap/soledad/common/document.py new file mode 100644 index 00000000..cc24b53a --- /dev/null +++ b/common/src/leap/soledad/common/document.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +# document.py +# Copyright (C) 2013 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +""" +A Soledad Document is an u1db.Document with lasers. +""" + + +from u1db import Document + + +# +# SoledadDocument +# + +class SoledadDocument(Document): +    """ +    Encryptable and syncable document. + +    LEAP Documents can be flagged as syncable or not, so the replicas +    might not sync every document. +    """ + +    def __init__(self, doc_id=None, rev=None, json='{}', has_conflicts=False, +                 syncable=True): +        """ +        Container for handling an encryptable document. + +        @param doc_id: The unique document identifier. +        @type doc_id: str +        @param rev: The revision identifier of the document. +        @type rev: str +        @param json: The JSON string for this document. +        @type json: str +        @param has_conflicts: Boolean indicating if this document has conflicts +        @type has_conflicts: bool +        @param syncable: Should this document be synced with remote replicas? +        @type syncable: bool +        """ +        Document.__init__(self, doc_id, rev, json, has_conflicts) +        self._syncable = syncable + +    def _get_syncable(self): +        """ +        Return whether this document is syncable. + +        @return: Is this document syncable? +        @rtype: bool +        """ +        return self._syncable + +    def _set_syncable(self, syncable=True): +        """ +        Determine if this document should be synced with remote replicas. + +        @param syncable: Should this document be synced with remote replicas? +        @type syncable: bool +        """ +        self._syncable = syncable + +    syncable = property( +        _get_syncable, +        _set_syncable, +        doc="Determine if document should be synced with server." +    ) + +    def _get_rev(self): +        """ +        Get the document revision. + +        Returning the revision as string solves the following exception in +        Twisted web: +            exceptions.TypeError: Can only pass-through bytes on Python 2 + +        @return: The document revision. +        @rtype: str +        """ +        if self._rev is None: +            return None +        return str(self._rev) + +    def _set_rev(self, rev): +        """ +        Set document revision. + +        @param rev: The new document revision. +        @type rev: bytes +        """ +        self._rev = rev + +    rev = property( +        _get_rev, +        _set_rev, +        doc="Wrapper to ensure `doc.rev` is always returned as bytes.") + + diff --git a/common/src/leap/soledad/common/objectstore.py b/common/src/leap/soledad/common/objectstore.py new file mode 100644 index 00000000..921cf075 --- /dev/null +++ b/common/src/leap/soledad/common/objectstore.py @@ -0,0 +1,297 @@ +# -*- coding: utf-8 -*- +# objectstore.py +# Copyright (C) 2013 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +""" +Abstract U1DB backend to handle storage using object stores (like CouchDB, for +example). + +Right now, this is only used by CouchDatabase backend, but can also be +extended to implement OpenStack or Amazon S3 storage, for example. + +See U1DB documentation for more information on how to use databases. +""" + + +from u1db import errors +from u1db.backends.inmemory import ( +    InMemoryDatabase, +    InMemorySyncTarget, +) + + +class ObjectStoreDatabase(InMemoryDatabase): +    """ +    A backend for storing u1db data in an object store. +    """ + +    @classmethod +    def open_database(cls, url, create, document_factory=None): +        """ +        Open a U1DB database using an object store as backend. + +        @param url: the url of the database replica +        @type url: str +        @param create: should the replica be created if it does not exist? +        @type create: bool +        @param document_factory: A function that will be called with the same +            parameters as Document.__init__. +        @type document_factory: callable + +        @return: the database instance +        @rtype: CouchDatabase +        """ +        raise NotImplementedError(cls.open_database) + +    def __init__(self, replica_uid=None, document_factory=None): +        """ +        Initialize the object store database. + +        @param replica_uid: an optional unique replica identifier +        @type replica_uid: str +        @param document_factory: A function that will be called with the same +            parameters as Document.__init__. +        @type document_factory: callable +        """ +        InMemoryDatabase.__init__( +            self, +            replica_uid, +            document_factory=document_factory) +        # sync data in memory with data in object store +        if not self._get_doc(self.U1DB_DATA_DOC_ID): +            self._init_u1db_data() +        self._fetch_u1db_data() + +    #------------------------------------------------------------------------- +    # methods from Database +    #------------------------------------------------------------------------- + +    def _set_replica_uid(self, replica_uid): +        """ +        Force the replica_uid to be set. + +        @param replica_uid: The uid of the replica. +        @type replica_uid: str +        """ +        InMemoryDatabase._set_replica_uid(self, replica_uid) +        self._store_u1db_data() + +    def _put_doc(self, doc): +        """ +        Update a document. + +        This is called everytime we just want to do a raw put on the db (i.e. +        without index updates, document constraint checks, and conflict +        checks). + +        @param doc: The document to update. +        @type doc: u1db.Document + +        @return: The new revision identifier for the document. +        @rtype: str +        """ +        raise NotImplementedError(self._put_doc) + +    def _get_doc(self, doc_id): +        """ +        Get just the document content, without fancy handling. + +        @param doc_id: The unique document identifier +        @type doc_id: str +        @param include_deleted: If set to True, deleted documents will be +            returned with empty content. Otherwise asking for a deleted +            document will return None. +        @type include_deleted: bool + +        @return: a Document object. +        @type: u1db.Document +        """ +        raise NotImplementedError(self._get_doc) + +    def get_all_docs(self, include_deleted=False): +        """ +        Get the JSON content for all documents in the database. + +        @param include_deleted: If set to True, deleted documents will be +            returned with empty content. Otherwise deleted documents will not +            be included in the results. +        @type include_deleted: bool + +        @return: (generation, [Document]) +            The current generation of the database, followed by a list of all +            the documents in the database. +        @rtype: tuple +        """ +        raise NotImplementedError(self.get_all_docs) + +    def delete_doc(self, doc): +        """ +        Mark a document as deleted. + +        @param doc: The document to mark as deleted. +        @type doc: u1db.Document + +        @return: The new revision id of the document. +        @type: str +        """ +        old_doc = self._get_doc(doc.doc_id, check_for_conflicts=True) +        if old_doc is None: +            raise errors.DocumentDoesNotExist +        if old_doc.rev != doc.rev: +            raise errors.RevisionConflict() +        if old_doc.is_tombstone(): +            raise errors.DocumentAlreadyDeleted +        if old_doc.has_conflicts: +            raise errors.ConflictedDoc() +        new_rev = self._allocate_doc_rev(doc.rev) +        doc.rev = new_rev +        doc.make_tombstone() +        self._put_and_update_indexes(old_doc, doc) +        return new_rev + +    # index-related methods + +    def create_index(self, index_name, *index_expressions): +        """ +        Create a named index, which can then be queried for future lookups. + +        See U1DB documentation for more information. + +        @param index_name: A unique name which can be used as a key prefix. +        @param index_expressions: Index expressions defining the index +            information. +        """ +        raise NotImplementedError(self.create_index) + +    def delete_index(self, index_name): +        """ +        Remove a named index. + +        Here we just guarantee that the new info will be stored in the backend +        db after update. + +        @param index_name: The name of the index we are removing. +        @type index_name: str +        """ +        InMemoryDatabase.delete_index(self, index_name) +        self._store_u1db_data() + +    def _replace_conflicts(self, doc, conflicts): +        """ +        Set new conflicts for a document. + +        Here we just guarantee that the new info will be stored in the backend +        db after update. + +        @param doc: The document with a new set of conflicts. +        @param conflicts: The new set of conflicts. +        @type conflicts: list +        """ +        InMemoryDatabase._replace_conflicts(self, doc, conflicts) +        self._store_u1db_data() + +    def _do_set_replica_gen_and_trans_id(self, other_replica_uid, +                                         other_generation, +                                         other_transaction_id): +        """ +        Set the last-known generation and transaction id for the other +        database replica. + +        Here we just guarantee that the new info will be stored in the backend +        db after update. + +        @param other_replica_uid: The U1DB identifier for the other replica. +        @type other_replica_uid: str +        @param other_generation: The generation number for the other replica. +        @type other_generation: int +        @param other_transaction_id: The transaction id associated with the +            generation. +        @type other_transaction_id: str +        """ +        InMemoryDatabase._do_set_replica_gen_and_trans_id( +            self, +            other_replica_uid, +            other_generation, +            other_transaction_id) +        self._store_u1db_data() + +    #------------------------------------------------------------------------- +    # implemented methods from CommonBackend +    #------------------------------------------------------------------------- + +    def _put_and_update_indexes(self, old_doc, doc): +        """ +        Update a document and all indexes related to it. + +        @param old_doc: The old version of the document. +        @type old_doc: u1db.Document +        @param doc: The new version of the document. +        @type doc: u1db.Document +        """ +        for index in self._indexes.itervalues(): +            if old_doc is not None and not old_doc.is_tombstone(): +                index.remove_json(old_doc.doc_id, old_doc.get_json()) +            if not doc.is_tombstone(): +                index.add_json(doc.doc_id, doc.get_json()) +        trans_id = self._allocate_transaction_id() +        self._put_doc(doc) +        self._transaction_log.append((doc.doc_id, trans_id)) +        self._store_u1db_data() + +    #------------------------------------------------------------------------- +    # methods specific for object stores +    #------------------------------------------------------------------------- + +    U1DB_DATA_DOC_ID = 'u1db_data' + +    def _fetch_u1db_data(self): +        """ +        Fetch u1db configuration data from backend storage. + +        See C{_init_u1db_data} documentation. +        """ +        NotImplementedError(self._fetch_u1db_data) + +    def _store_u1db_data(self): +        """ +        Store u1db configuration data on backend storage. + +        See C{_init_u1db_data} documentation. +        """ +        NotImplementedError(self._store_u1db_data) + +    def _init_u1db_data(self): +        """ +        Initialize u1db configuration data on backend storage. + +        A U1DB database needs to keep track of all database transactions, +        document conflicts, the generation of other replicas it has seen, +        indexes created by users and so on. + +        In this implementation, all this information is stored in a special +        document stored in the couch db with id equals to +        CouchDatabse.U1DB_DATA_DOC_ID. + +        This method initializes the document that will hold such information. +        """ +        NotImplementedError(self._init_u1db_data) + + +class ObjectStoreSyncTarget(InMemorySyncTarget): +    """ +    Functionality for using an ObjectStore as a synchronization target. +    """ diff --git a/common/src/leap/soledad/common/tests/__init__.py b/common/src/leap/soledad/common/tests/__init__.py new file mode 100644 index 00000000..9f47d74a --- /dev/null +++ b/common/src/leap/soledad/common/tests/__init__.py @@ -0,0 +1,269 @@ +""" +Tests to make sure Soledad provides U1DB functionality and more. +""" + +import os +import random +import string +import u1db +from mock import Mock + + +from leap.soledad.common.document import SoledadDocument +from leap.soledad.client import Soledad +from leap.soledad.client.crypto import SoledadCrypto +from leap.soledad.client.target import ( +    decrypt_doc, +    ENC_SCHEME_KEY, +) +from leap.common.testing.basetest import BaseLeapTest + + +#----------------------------------------------------------------------------- +# Some tests inherit from BaseSoledadTest in order to have a working Soledad +# instance in each test. +#----------------------------------------------------------------------------- + +ADDRESS = 'leap@leap.se' + + +class BaseSoledadTest(BaseLeapTest): +    """ +    Instantiates Soledad for usage in tests. +    """ + +    def setUp(self): +        # config info +        self.db1_file = os.path.join(self.tempdir, "db1.u1db") +        self.db2_file = os.path.join(self.tempdir, "db2.u1db") +        self.email = ADDRESS +        # open test dbs +        self._db1 = u1db.open(self.db1_file, create=True, +                              document_factory=SoledadDocument) +        self._db2 = u1db.open(self.db2_file, create=True, +                              document_factory=SoledadDocument) +        # get a random prefix for each test, so we do not mess with +        # concurrency during initialization and shutting down of +        # each local db. +        self.rand_prefix = ''.join( +            map(lambda x: random.choice(string.ascii_letters), range(6))) +        # initialize soledad by hand so we can control keys +        self._soledad = self._soledad_instance( +            prefix=self.rand_prefix, user=self.email) + +    def tearDown(self): +        self._db1.close() +        self._db2.close() +        self._soledad.close() +        # XXX should not access "private" attrs +        for f in [self._soledad._local_db_path, self._soledad._secrets_path]: +            if os.path.isfile(f): +                os.unlink(f) + +    def _soledad_instance(self, user=ADDRESS, passphrase='123', +                          prefix='', +                          secrets_path=Soledad.STORAGE_SECRETS_FILE_NAME, +                          local_db_path='soledad.u1db', server_url='', +                          cert_file=None, secret_id=None): + +        def _put_doc_side_effect(doc): +            self._doc_put = doc + +        class MockSharedDB(object): + +            get_doc = Mock(return_value=None) +            put_doc = Mock(side_effect=_put_doc_side_effect) + +            def __call__(self): +                return self + +        Soledad._shared_db = MockSharedDB() +        return Soledad( +            user, +            passphrase, +            secrets_path=os.path.join( +                self.tempdir, prefix, secrets_path), +            local_db_path=os.path.join( +                self.tempdir, prefix, local_db_path), +            server_url=server_url,  # Soledad will fail if not given an url. +            cert_file=cert_file, +            secret_id=secret_id) + +    def assertGetEncryptedDoc( +            self, db, doc_id, doc_rev, content, has_conflicts): +        """ +        Assert that the document in the database looks correct. +        """ +        exp_doc = self.make_document(doc_id, doc_rev, content, +                                     has_conflicts=has_conflicts) +        doc = db.get_doc(doc_id) +        if ENC_SCHEME_KEY in doc.content: +            doc.set_json(decrypt_doc(self._soledad._crypto, doc)) +        self.assertEqual(exp_doc.doc_id, doc.doc_id) +        self.assertEqual(exp_doc.rev, doc.rev) +        self.assertEqual(exp_doc.has_conflicts, doc.has_conflicts) +        self.assertEqual(exp_doc.content, doc.content) + + +# Key material for testing +KEY_FINGERPRINT = "E36E738D69173C13D709E44F2F455E2824D18DDF" +PUBLIC_KEY = """ +-----BEGIN PGP PUBLIC KEY BLOCK----- +Version: GnuPG v1.4.10 (GNU/Linux) + +mQINBFC9+dkBEADNRfwV23TWEoGc/x0wWH1P7PlXt8MnC2Z1kKaKKmfnglVrpOiz +iLWoiU58sfZ0L5vHkzXHXCBf6Eiy/EtUIvdiWAn+yASJ1mk5jZTBKO/WMAHD8wTO +zpMsFmWyg3xc4DkmFa9KQ5EVU0o/nqPeyQxNMQN7px5pPwrJtJFmPxnxm+aDkPYx +irDmz/4DeDNqXliazGJKw7efqBdlwTHkl9Akw2gwy178pmsKwHHEMOBOFFvX61AT +huKqHYmlCGSliwbrJppTG7jc1/ls3itrK+CWTg4txREkSpEVmfcASvw/ZqLbjgfs +d/INMwXnR9U81O8+7LT6yw/ca4ppcFoJD7/XJbkRiML6+bJ4Dakiy6i727BzV17g +wI1zqNvm5rAhtALKfACha6YO43aJzairO4II1wxVHvRDHZn2IuKDDephQ3Ii7/vb +hUOf6XCSmchkAcpKXUOvbxm1yfB1LRa64mMc2RcZxf4mW7KQkulBsdV5QG2276lv +U2UUy2IutXcGP5nXC+f6sJJGJeEToKJ57yiO/VWJFjKN8SvP+7AYsQSqINUuEf6H +T5gCPCraGMkTUTPXrREvu7NOohU78q6zZNaL3GW8ai7eSeANSuQ8Vzffx7Wd8Y7i +Pw9sYj0SMFs1UgjbuL6pO5ueHh+qyumbtAq2K0Bci0kqOcU4E9fNtdiovQARAQAB +tBxMZWFwIFRlc3QgS2V5IDxsZWFwQGxlYXAuc2U+iQI3BBMBCAAhBQJQvfnZAhsD +BQsJCAcDBRUKCQgLBRYCAwEAAh4BAheAAAoJEC9FXigk0Y3fT7EQAKH3IuRniOpb +T/DDIgwwjz3oxB/W0DDMyPXowlhSOuM0rgGfntBpBb3boezEXwL86NPQxNGGruF5 +hkmecSiuPSvOmQlqlS95NGQp6hNG0YaKColh+Q5NTspFXCAkFch9oqUje0LdxfSP +QfV9UpeEvGyPmk1I9EJV/YDmZ4+Djge1d7qhVZInz4Rx1NrSyF/Tc2EC0VpjQFsU +Y9Kb2YBBR7ivG6DBc8ty0jJXi7B4WjkFcUEJviQpMF2dCLdonCehYs1PqsN1N7j+ +eFjQd+hqVMJgYuSGKjvuAEfClM6MQw7+FmFwMyLgK/Ew/DttHEDCri77SPSkOGSI +txCzhTg6798f6mJr7WcXmHX1w1Vcib5FfZ8vTDFVhz/XgAgArdhPo9V6/1dgSSiB +KPQ/spsco6u5imdOhckERE0lnAYvVT6KE81TKuhF/b23u7x+Wdew6kK0EQhYA7wy +7LmlaNXc7rMBQJ9Z60CJ4JDtatBWZ0kNrt2VfdDHVdqBTOpl0CraNUjWE5YMDasr +K2dF5IX8D3uuYtpZnxqg0KzyLg0tzL0tvOL1C2iudgZUISZNPKbS0z0v+afuAAnx +2pTC3uezbh2Jt8SWTLhll4i0P4Ps5kZ6HQUO56O+/Z1cWovX+mQekYFmERySDR9n +3k1uAwLilJmRmepGmvYbB8HloV8HqwgguQINBFC9+dkBEAC0I/xn1uborMgDvBtf +H0sEhwnXBC849/32zic6udB6/3Efk9nzbSpL3FSOuXITZsZgCHPkKarnoQ2ztMcS +sh1ke1C5gQGms75UVmM/nS+2YI4vY8OX/GC/on2vUyncqdH+bR6xH5hx4NbWpfTs +iQHmz5C6zzS/kuabGdZyKRaZHt23WQ7JX/4zpjqbC99DjHcP9BSk7tJ8wI4bkMYD +uFVQdT9O6HwyKGYwUU4sAQRAj7XCTGvVbT0dpgJwH4RmrEtJoHAx4Whg8mJ710E0 +GCmzf2jqkNuOw76ivgk27Kge+Hw00jmJjQhHY0yVbiaoJwcRrPKzaSjEVNgrpgP3 +lXPRGQArgESsIOTeVVHQ8fhK2YtTeCY9rIiO+L0OX2xo9HK7hfHZZWL6rqymXdyS +fhzh/f6IPyHFWnvj7Brl7DR8heMikygcJqv+ed2yx7iLyCUJ10g12I48+aEj1aLe +dP7lna32iY8/Z0SHQLNH6PXO9SlPcq2aFUgKqE75A/0FMk7CunzU1OWr2ZtTLNO1 +WT/13LfOhhuEq9jTyTosn0WxBjJKq18lnhzCXlaw6EAtbA7CUwsD3CTPR56aAXFK +3I7KXOVAqggrvMe5Tpdg5drfYpI8hZovL5aAgb+7Y5ta10TcJdUhS5K3kFAWe/td +U0cmWUMDP1UMSQ5Jg6JIQVWhSwARAQABiQIfBBgBCAAJBQJQvfnZAhsMAAoJEC9F +Xigk0Y3fRwsP/i0ElYCyxeLpWJTwo1iCLkMKz2yX1lFVa9nT1BVTPOQwr/IAc5OX +NdtbJ14fUsKL5pWgW8OmrXtwZm1y4euI1RPWWubG01ouzwnGzv26UcuHeqC5orZj +cOnKtL40y8VGMm8LoicVkRJH8blPORCnaLjdOtmA3rx/v2EXrJpSa3AhOy0ZSRXk +ZSrK68AVNwamHRoBSYyo0AtaXnkPX4+tmO8X8BPfj125IljubvwZPIW9VWR9UqCE +VPfDR1XKegVb6VStIywF7kmrknM1C5qUY28rdZYWgKorw01hBGV4jTW0cqde3N51 +XT1jnIAa+NoXUM9uQoGYMiwrL7vNsLlyyiW5ayDyV92H/rIuiqhFgbJsHTlsm7I8 +oGheR784BagAA1NIKD1qEO9T6Kz9lzlDaeWS5AUKeXrb7ZJLI1TTCIZx5/DxjLqM +Tt/RFBpVo9geZQrvLUqLAMwdaUvDXC2c6DaCPXTh65oCZj/hqzlJHH+RoTWWzKI+ +BjXxgUWF9EmZUBrg68DSmI+9wuDFsjZ51BcqvJwxyfxtTaWhdoYqH/UQS+D1FP3/ +diZHHlzwVwPICzM9ooNTgbrcDzyxRkIVqsVwBq7EtzcvgYUyX53yG25Giy6YQaQ2 +ZtQ/VymwFL3XdUWV6B/hU4PVAFvO3qlOtdJ6TpE+nEWgcWjCv5g7RjXX +=MuOY +-----END PGP PUBLIC KEY BLOCK----- +""" +PRIVATE_KEY = """ +-----BEGIN PGP PRIVATE KEY BLOCK----- +Version: GnuPG v1.4.10 (GNU/Linux) + +lQcYBFC9+dkBEADNRfwV23TWEoGc/x0wWH1P7PlXt8MnC2Z1kKaKKmfnglVrpOiz +iLWoiU58sfZ0L5vHkzXHXCBf6Eiy/EtUIvdiWAn+yASJ1mk5jZTBKO/WMAHD8wTO +zpMsFmWyg3xc4DkmFa9KQ5EVU0o/nqPeyQxNMQN7px5pPwrJtJFmPxnxm+aDkPYx +irDmz/4DeDNqXliazGJKw7efqBdlwTHkl9Akw2gwy178pmsKwHHEMOBOFFvX61AT +huKqHYmlCGSliwbrJppTG7jc1/ls3itrK+CWTg4txREkSpEVmfcASvw/ZqLbjgfs +d/INMwXnR9U81O8+7LT6yw/ca4ppcFoJD7/XJbkRiML6+bJ4Dakiy6i727BzV17g +wI1zqNvm5rAhtALKfACha6YO43aJzairO4II1wxVHvRDHZn2IuKDDephQ3Ii7/vb +hUOf6XCSmchkAcpKXUOvbxm1yfB1LRa64mMc2RcZxf4mW7KQkulBsdV5QG2276lv +U2UUy2IutXcGP5nXC+f6sJJGJeEToKJ57yiO/VWJFjKN8SvP+7AYsQSqINUuEf6H +T5gCPCraGMkTUTPXrREvu7NOohU78q6zZNaL3GW8ai7eSeANSuQ8Vzffx7Wd8Y7i +Pw9sYj0SMFs1UgjbuL6pO5ueHh+qyumbtAq2K0Bci0kqOcU4E9fNtdiovQARAQAB +AA/+JHtlL39G1wsH9R6UEfUQJGXR9MiIiwZoKcnRB2o8+DS+OLjg0JOh8XehtuCs +E/8oGQKtQqa5bEIstX7IZoYmYFiUQi9LOzIblmp2vxOm+HKkxa4JszWci2/ZmC3t +KtaA4adl9XVnshoQ7pijuCMUKB3naBEOAxd8s9d/JeReGIYkJErdrnVfNk5N71Ds +FmH5Ll3XtEDvgBUQP3nkA6QFjpsaB94FHjL3gDwum/cxzj6pCglcvHOzEhfY0Ddb +J967FozQTaf2JW3O+w3LOqtcKWpq87B7+O61tVidQPSSuzPjCtFF0D2LC9R/Hpky +KTMQ6CaKja4MPhjwywd4QPcHGYSqjMpflvJqi+kYIt8psUK/YswWjnr3r4fbuqVY +VhtiHvnBHQjz135lUqWvEz4hM3Xpnxydx7aRlv5NlevK8+YIO5oFbWbGNTWsPZI5 +jpoFBpSsnR1Q5tnvtNHauvoWV+XN2qAOBTG+/nEbDYH6Ak3aaE9jrpTdYh0CotYF +q7csANsDy3JvkAzeU6WnYpsHHaAjqOGyiZGsLej1UcXPFMosE/aUo4WQhiS8Zx2c +zOVKOi/X5vQ2GdNT9Qolz8AriwzsvFR+bxPzyd8V6ALwDsoXvwEYinYBKK8j0OPv +OOihSR6HVsuP9NUZNU9ewiGzte/+/r6pNXHvR7wTQ8EWLcEIAN6Zyrb0bHZTIlxt +VWur/Ht2mIZrBaO50qmM5RD3T5oXzWXi/pjLrIpBMfeZR9DWfwQwjYzwqi7pxtYx +nJvbMuY505rfnMoYxb4J+cpRXV8MS7Dr1vjjLVUC9KiwSbM3gg6emfd2yuA93ihv +Pe3mffzLIiQa4mRE3wtGcioC43nWuV2K2e1KjxeFg07JhrezA/1Cak505ab/tmvP +4YmjR5c44+yL/YcQ3HdFgs4mV+nVbptRXvRcPpolJsgxPccGNdvHhsoR4gwXMS3F +RRPD2z6x8xeN73Q4KH3bm01swQdwFBZbWVfmUGLxvN7leCdfs9+iFJyqHiCIB6Iv +mQfp8F0IAOwSo8JhWN+V1dwML4EkIrM8wUb4yecNLkyR6TpPH/qXx4PxVMC+vy6x +sCtjeHIwKE+9vqnlhd5zOYh7qYXEJtYwdeDDmDbL8oks1LFfd+FyAuZXY33DLwn0 +cRYsr2OEZmaajqUB3NVmj3H4uJBN9+paFHyFSXrH68K1Fk2o3n+RSf2EiX+eICwI +L6rqoF5sSVUghBWdNegV7qfy4anwTQwrIMGjgU5S6PKW0Dr/3iO5z3qQpGPAj5OW +ATqPWkDICLbObPxD5cJlyyNE2wCA9VVc6/1d6w4EVwSq9h3/WTpATEreXXxTGptd +LNiTA1nmakBYNO2Iyo3djhaqBdWjk+EIAKtVEnJH9FAVwWOvaj1RoZMA5DnDMo7e +SnhrCXl8AL7Z1WInEaybasTJXn1uQ8xY52Ua4b8cbuEKRKzw/70NesFRoMLYoHTO +dyeszvhoDHberpGRTciVmpMu7Hyi33rM31K9epA4ib6QbbCHnxkWOZB+Bhgj1hJ8 +xb4RBYWiWpAYcg0+DAC3w9gfxQhtUlZPIbmbrBmrVkO2GVGUj8kH6k4UV6kUHEGY +HQWQR0HcbKcXW81ZXCCD0l7ROuEWQtTe5Jw7dJ4/QFuqZnPutXVRNOZqpl6eRShw +7X2/a29VXBpmHA95a88rSQsL+qm7Fb3prqRmuMCtrUZgFz7HLSTuUMR867QcTGVh +cCBUZXN0IEtleSA8bGVhcEBsZWFwLnNlPokCNwQTAQgAIQUCUL352QIbAwULCQgH +AwUVCgkICwUWAgMBAAIeAQIXgAAKCRAvRV4oJNGN30+xEACh9yLkZ4jqW0/wwyIM +MI896MQf1tAwzMj16MJYUjrjNK4Bn57QaQW926HsxF8C/OjT0MTRhq7heYZJnnEo +rj0rzpkJapUveTRkKeoTRtGGigqJYfkOTU7KRVwgJBXIfaKlI3tC3cX0j0H1fVKX +hLxsj5pNSPRCVf2A5mePg44HtXe6oVWSJ8+EcdTa0shf03NhAtFaY0BbFGPSm9mA +QUe4rxugwXPLctIyV4uweFo5BXFBCb4kKTBdnQi3aJwnoWLNT6rDdTe4/nhY0Hfo +alTCYGLkhio77gBHwpTOjEMO/hZhcDMi4CvxMPw7bRxAwq4u+0j0pDhkiLcQs4U4 +Ou/fH+pia+1nF5h19cNVXIm+RX2fL0wxVYc/14AIAK3YT6PVev9XYEkogSj0P7Kb +HKOruYpnToXJBERNJZwGL1U+ihPNUyroRf29t7u8flnXsOpCtBEIWAO8Muy5pWjV +3O6zAUCfWetAieCQ7WrQVmdJDa7dlX3Qx1XagUzqZdAq2jVI1hOWDA2rKytnReSF +/A97rmLaWZ8aoNCs8i4NLcy9Lbzi9QtornYGVCEmTTym0tM9L/mn7gAJ8dqUwt7n +s24dibfElky4ZZeItD+D7OZGeh0FDuejvv2dXFqL1/pkHpGBZhEckg0fZ95NbgMC +4pSZkZnqRpr2GwfB5aFfB6sIIJ0HGARQvfnZARAAtCP8Z9bm6KzIA7wbXx9LBIcJ +1wQvOPf99s4nOrnQev9xH5PZ820qS9xUjrlyE2bGYAhz5Cmq56ENs7THErIdZHtQ +uYEBprO+VFZjP50vtmCOL2PDl/xgv6J9r1Mp3KnR/m0esR+YceDW1qX07IkB5s+Q +us80v5LmmxnWcikWmR7dt1kOyV/+M6Y6mwvfQ4x3D/QUpO7SfMCOG5DGA7hVUHU/ +Tuh8MihmMFFOLAEEQI+1wkxr1W09HaYCcB+EZqxLSaBwMeFoYPJie9dBNBgps39o +6pDbjsO+or4JNuyoHvh8NNI5iY0IR2NMlW4mqCcHEazys2koxFTYK6YD95Vz0RkA +K4BErCDk3lVR0PH4StmLU3gmPayIjvi9Dl9saPRyu4Xx2WVi+q6spl3ckn4c4f3+ +iD8hxVp74+wa5ew0fIXjIpMoHCar/nndsse4i8glCddINdiOPPmhI9Wi3nT+5Z2t +9omPP2dEh0CzR+j1zvUpT3KtmhVICqhO+QP9BTJOwrp81NTlq9mbUyzTtVk/9dy3 +zoYbhKvY08k6LJ9FsQYySqtfJZ4cwl5WsOhALWwOwlMLA9wkz0eemgFxStyOylzl +QKoIK7zHuU6XYOXa32KSPIWaLy+WgIG/u2ObWtdE3CXVIUuSt5BQFnv7XVNHJllD +Az9VDEkOSYOiSEFVoUsAEQEAAQAP/1AagnZQZyzHDEgw4QELAspYHCWLXE5aZInX +wTUJhK31IgIXNn9bJ0hFiSpQR2xeMs9oYtRuPOu0P8oOFMn4/z374fkjZy8QVY3e +PlL+3EUeqYtkMwlGNmVw5a/NbNuNfm5Darb7pEfbYd1gPcni4MAYw7R2SG/57GbC +9gucvspHIfOSfBNLBthDzmK8xEKe1yD2eimfc2T7IRYb6hmkYfeds5GsqvGI6mwI +85h4uUHWRc5JOlhVM6yX8hSWx0L60Z3DZLChmc8maWnFXd7C8eQ6P1azJJbW71Ih +7CoK0XW4LE82vlQurSRFgTwfl7wFYszW2bOzCuhHDDtYnwH86Nsu0DC78ZVRnvxn +E8Ke/AJgrdhIOo4UAyR+aZD2+2mKd7/waOUTUrUtTzc7i8N3YXGi/EIaNReBXaq+ +ZNOp24BlFzRp+FCF/pptDW9HjPdiV09x0DgICmeZS4Gq/4vFFIahWctg52NGebT0 +Idxngjj+xDtLaZlLQoOz0n5ByjO/Wi0ANmMv1sMKCHhGvdaSws2/PbMR2r4caj8m +KXpIgdinM/wUzHJ5pZyF2U/qejsRj8Kw8KH/tfX4JCLhiaP/mgeTuWGDHeZQERAT +xPmRFHaLP9/ZhvGNh6okIYtrKjWTLGoXvKLHcrKNisBLSq+P2WeFrlme1vjvJMo/ +jPwLT5o9CADQmcbKZ+QQ1ZM9v99iDZol7SAMZX43JC019sx6GK0u6xouJBcLfeB4 +OXacTgmSYdTa9RM9fbfVpti01tJ84LV2SyL/VJq/enJF4XQPSynT/tFTn1PAor6o +tEAAd8fjKdJ6LnD5wb92SPHfQfXqI84rFEO8rUNIE/1ErT6DYifDzVCbfD2KZdoF +cOSp7TpD77sY1bs74ocBX5ejKtd+aH99D78bJSMM4pSDZsIEwnomkBHTziubPwJb +OwnATy0LmSMAWOw5rKbsh5nfwCiUTM20xp0t5JeXd+wPVWbpWqI2EnkCEN+RJr9i +7dp/ymDQ+Yt5wrsN3NwoyiexPOG91WQVCADdErHsnglVZZq9Z8Wx7KwecGCUurJ2 +H6lKudv5YOxPnAzqZS5HbpZd/nRTMZh2rdXCr5m2YOuewyYjvM757AkmUpM09zJX +MQ1S67/UX2y8/74TcRF97Ncx9HeELs92innBRXoFitnNguvcO6Esx4BTe1OdU6qR +ER3zAmVf22Le9ciXbu24DN4mleOH+OmBx7X2PqJSYW9GAMTsRB081R6EWKH7romQ +waxFrZ4DJzZ9ltyosEJn5F32StyLrFxpcrdLUoEaclZCv2qka7sZvi0EvovDVEBU +e10jOx9AOwf8Gj2ufhquQ6qgVYCzbP+YrodtkFrXRS3IsljIchj1M2ffB/0bfoUs +rtER9pLvYzCjBPg8IfGLw0o754Qbhh/ReplCRTusP/fQMybvCvfxreS3oyEriu/G +GufRomjewZ8EMHDIgUsLcYo2UHZsfF7tcazgxMGmMvazp4r8vpgrvW/8fIN/6Adu +tF+WjWDTvJLFJCe6O+BFJOWrssNrrra1zGtLC1s8s+Wfpe+bGPL5zpHeebGTwH1U +22eqgJArlEKxrfarz7W5+uHZJHSjF/K9ZvunLGD0n9GOPMpji3UO3zeM8IYoWn7E +/EWK1XbjnssNemeeTZ+sDh+qrD7BOi+vCX1IyBxbfqnQfJZvmcPWpruy1UsO+aIC +0GY8Jr3OL69dDQ21jueJAh8EGAEIAAkFAlC9+dkCGwwACgkQL0VeKCTRjd9HCw/+ +LQSVgLLF4ulYlPCjWIIuQwrPbJfWUVVr2dPUFVM85DCv8gBzk5c121snXh9Swovm +laBbw6ate3BmbXLh64jVE9Za5sbTWi7PCcbO/bpRy4d6oLmitmNw6cq0vjTLxUYy +bwuiJxWREkfxuU85EKdouN062YDevH+/YResmlJrcCE7LRlJFeRlKsrrwBU3BqYd +GgFJjKjQC1peeQ9fj62Y7xfwE9+PXbkiWO5u/Bk8hb1VZH1SoIRU98NHVcp6BVvp +VK0jLAXuSauSczULmpRjbyt1lhaAqivDTWEEZXiNNbRyp17c3nVdPWOcgBr42hdQ +z25CgZgyLCsvu82wuXLKJblrIPJX3Yf+si6KqEWBsmwdOWybsjygaF5HvzgFqAAD +U0goPWoQ71PorP2XOUNp5ZLkBQp5etvtkksjVNMIhnHn8PGMuoxO39EUGlWj2B5l +Cu8tSosAzB1pS8NcLZzoNoI9dOHrmgJmP+GrOUkcf5GhNZbMoj4GNfGBRYX0SZlQ +GuDrwNKYj73C4MWyNnnUFyq8nDHJ/G1NpaF2hiof9RBL4PUU/f92JkceXPBXA8gL +Mz2ig1OButwPPLFGQhWqxXAGrsS3Ny+BhTJfnfIbbkaLLphBpDZm1D9XKbAUvdd1 +RZXoH+FTg9UAW87eqU610npOkT6cRaBxaMK/mDtGNdc= +=JTFu +-----END PGP PRIVATE KEY BLOCK----- +""" diff --git a/common/src/leap/soledad/common/tests/couchdb.ini.template b/common/src/leap/soledad/common/tests/couchdb.ini.template new file mode 100644 index 00000000..7d0316f0 --- /dev/null +++ b/common/src/leap/soledad/common/tests/couchdb.ini.template @@ -0,0 +1,222 @@ +; etc/couchdb/default.ini.tpl.  Generated from default.ini.tpl.in by configure. + +; Upgrading CouchDB will overwrite this file. + +[couchdb] +database_dir = %(tempdir)s/lib +view_index_dir = %(tempdir)s/lib +max_document_size = 4294967296 ; 4 GB +os_process_timeout = 5000 ; 5 seconds. for view and external servers. +max_dbs_open = 100 +delayed_commits = true ; set this to false to ensure an fsync before 201 Created is returned +uri_file = %(tempdir)s/lib/couch.uri +file_compression = snappy + +[database_compaction] +; larger buffer sizes can originate smaller files +doc_buffer_size = 524288 ; value in bytes +checkpoint_after = 5242880 ; checkpoint after every N bytes were written + +[view_compaction] +; larger buffer sizes can originate smaller files +keyvalue_buffer_size = 2097152 ; value in bytes + +[httpd] +port = 0 +bind_address = 127.0.0.1 +authentication_handlers = {couch_httpd_oauth, oauth_authentication_handler}, {couch_httpd_auth, cookie_authentication_handler}, {couch_httpd_auth, default_authentication_handler} +default_handler = {couch_httpd_db, handle_request} +secure_rewrites = true +vhost_global_handlers = _utils, _uuids, _session, _oauth, _users +allow_jsonp = false +; Options for the MochiWeb HTTP server. +;server_options = [{backlog, 128}, {acceptor_pool_size, 16}] +; For more socket options, consult Erlang's module 'inet' man page. +;socket_options = [{recbuf, 262144}, {sndbuf, 262144}, {nodelay, true}] +log_max_chunk_size = 1000000 + +[log] +file = %(tempdir)s/log/couch.log +level = info +include_sasl = true + +[couch_httpd_auth] +authentication_db = _users +authentication_redirect = /_utils/session.html +require_valid_user = false +timeout = 600 ; number of seconds before automatic logout +auth_cache_size = 50 ; size is number of cache entries +allow_persistent_cookies = false ; set to true to allow persistent cookies + +[couch_httpd_oauth] +; If set to 'true', oauth token and consumer secrets will be looked up +; in the authentication database (_users). These secrets are stored in +; a top level property named "oauth" in user documents. Example: +;     { +;         "_id": "org.couchdb.user:joe", +;         "type": "user", +;         "name": "joe", +;         "password_sha": "fe95df1ca59a9b567bdca5cbaf8412abd6e06121", +;         "salt": "4e170ffeb6f34daecfd814dfb4001a73" +;         "roles": ["foo", "bar"], +;         "oauth": { +;             "consumer_keys": { +;                 "consumerKey1": "key1Secret", +;                 "consumerKey2": "key2Secret" +;             }, +;             "tokens": { +;                 "token1": "token1Secret", +;                 "token2": "token2Secret" +;             } +;         } +;     } +use_users_db = false + +[query_servers] +; javascript = %(tempdir)s/server/main.js + + +; Changing reduce_limit to false will disable reduce_limit. +; If you think you're hitting reduce_limit with a "good" reduce function, +; please let us know on the mailing list so we can fine tune the heuristic. +[query_server_config] +reduce_limit = true +os_process_limit = 25 + +[daemons] +view_manager={couch_view, start_link, []} +external_manager={couch_external_manager, start_link, []} +query_servers={couch_query_servers, start_link, []} +vhosts={couch_httpd_vhost, start_link, []} +httpd={couch_httpd, start_link, []} +stats_aggregator={couch_stats_aggregator, start, []} +stats_collector={couch_stats_collector, start, []} +uuids={couch_uuids, start, []} +auth_cache={couch_auth_cache, start_link, []} +replication_manager={couch_replication_manager, start_link, []} +os_daemons={couch_os_daemons, start_link, []} +compaction_daemon={couch_compaction_daemon, start_link, []} + +[httpd_global_handlers] +/ = {couch_httpd_misc_handlers, handle_welcome_req, <<"Welcome">>} + +_all_dbs = {couch_httpd_misc_handlers, handle_all_dbs_req} +_active_tasks = {couch_httpd_misc_handlers, handle_task_status_req} +_config = {couch_httpd_misc_handlers, handle_config_req} +_replicate = {couch_httpd_replicator, handle_req} +_uuids = {couch_httpd_misc_handlers, handle_uuids_req} +_restart = {couch_httpd_misc_handlers, handle_restart_req} +_stats = {couch_httpd_stats_handlers, handle_stats_req} +_log = {couch_httpd_misc_handlers, handle_log_req} +_session = {couch_httpd_auth, handle_session_req} +_oauth = {couch_httpd_oauth, handle_oauth_req} + +[httpd_db_handlers] +_view_cleanup = {couch_httpd_db, handle_view_cleanup_req} +_compact = {couch_httpd_db, handle_compact_req} +_design = {couch_httpd_db, handle_design_req} +_temp_view = {couch_httpd_view, handle_temp_view_req} +_changes = {couch_httpd_db, handle_changes_req} + +; The external module takes an optional argument allowing you to narrow it to a +; single script. Otherwise the script name is inferred from the first path section +; after _external's own path. +; _mypath = {couch_httpd_external, handle_external_req, <<"mykey">>} +; _external = {couch_httpd_external, handle_external_req} + +[httpd_design_handlers] +_view = {couch_httpd_view, handle_view_req} +_show = {couch_httpd_show, handle_doc_show_req} +_list = {couch_httpd_show, handle_view_list_req} +_info = {couch_httpd_db,   handle_design_info_req} +_rewrite = {couch_httpd_rewrite, handle_rewrite_req} +_update = {couch_httpd_show, handle_doc_update_req} + +; enable external as an httpd handler, then link it with commands here. +; note, this api is still under consideration. +; [external] +; mykey = /path/to/mycommand + +; Here you can setup commands for CouchDB to manage +; while it is alive. It will attempt to keep each command +; alive if it exits. +; [os_daemons] +; some_daemon_name = /path/to/script -with args + + +[uuids] +; Known algorithms: +;   random - 128 bits of random awesome +;     All awesome, all the time. +;   sequential - monotonically increasing ids with random increments +;     First 26 hex characters are random. Last 6 increment in +;     random amounts until an overflow occurs. On overflow, the +;     random prefix is regenerated and the process starts over. +;   utc_random - Time since Jan 1, 1970 UTC with microseconds +;     First 14 characters are the time in hex. Last 18 are random. +algorithm = sequential + +[stats] +; rate is in milliseconds +rate = 1000 +; sample intervals are in seconds +samples = [0, 60, 300, 900] + +[attachments] +compression_level = 8 ; from 1 (lowest, fastest) to 9 (highest, slowest), 0 to disable compression +compressible_types = text/*, application/javascript, application/json, application/xml + +[replicator] +db = _replicator +; Maximum replicaton retry count can be a non-negative integer or "infinity". +max_replication_retry_count = 10 +; More worker processes can give higher network throughput but can also +; imply more disk and network IO. +worker_processes = 4 +; With lower batch sizes checkpoints are done more frequently. Lower batch sizes +; also reduce the total amount of used RAM memory. +worker_batch_size = 500 +; Maximum number of HTTP connections per replication. +http_connections = 20 +; HTTP connection timeout per replication. +; Even for very fast/reliable networks it might need to be increased if a remote +; database is too busy. +connection_timeout = 30000 +; If a request fails, the replicator will retry it up to N times. +retries_per_request = 10 +; Some socket options that might boost performance in some scenarios: +;       {nodelay, boolean()} +;       {sndbuf, integer()} +;       {recbuf, integer()} +;       {priority, integer()} +; See the `inet` Erlang module's man page for the full list of options. +socket_options = [{keepalive, true}, {nodelay, false}] +; Path to a file containing the user's certificate. +;cert_file = /full/path/to/server_cert.pem +; Path to file containing user's private PEM encoded key. +;key_file = /full/path/to/server_key.pem +; String containing the user's password. Only used if the private keyfile is password protected.  +;password = somepassword +; Set to true to validate peer certificates. +verify_ssl_certificates = false +; File containing a list of peer trusted certificates (in the PEM format). +;ssl_trusted_certificates_file = /etc/ssl/certs/ca-certificates.crt +; Maximum peer certificate depth (must be set even if certificate validation is off). +ssl_certificate_max_depth = 3 + +[compaction_daemon] +; The delay, in seconds, between each check for which database and view indexes +; need to be compacted. +check_interval = 300 +; If a database or view index file is smaller then this value (in bytes), +; compaction will not happen. Very small files always have a very high +; fragmentation therefore it's not worth to compact them. +min_file_size = 131072 + +[compactions] +; List of compaction rules for the compaction daemon. + + +;[admins] +;testuser = -hashed-f50a252c12615697c5ed24ec5cd56b05d66fe91e,b05471ba260132953930cf9f97f327f5 +; pass for above user is 'testpass'
\ No newline at end of file diff --git a/common/src/leap/soledad/common/tests/test_couch.py b/common/src/leap/soledad/common/tests/test_couch.py new file mode 100644 index 00000000..2fb799b7 --- /dev/null +++ b/common/src/leap/soledad/common/tests/test_couch.py @@ -0,0 +1,436 @@ +# -*- coding: utf-8 -*- +# test_couch.py +# Copyright (C) 2013 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +""" +Test ObjectStore and Couch backend bits. +""" + +import re +import copy +import shutil +from base64 import b64decode + +from leap.common.files import mkdir_p + +from leap.soledad.common.document import SoledadDocument +from leap.soledad.common.tests import u1db_tests as tests +from leap.soledad.common.tests.u1db_tests import test_backends +from leap.soledad.common.tests.u1db_tests import test_sync +from leap.soledad.common import couch +import simplejson as json + + +#----------------------------------------------------------------------------- +# A wrapper for running couchdb locally. +#----------------------------------------------------------------------------- + +import re +import os +import tempfile +import subprocess +import time +import unittest + + +# from: https://github.com/smcq/paisley/blob/master/paisley/test/util.py +# TODO: include license of above project. +class CouchDBWrapper(object): +    """ +    Wrapper for external CouchDB instance which is started and stopped for +    testing. +    """ + +    def start(self): +        """ +        Start a CouchDB instance for a test. +        """ +        self.tempdir = tempfile.mkdtemp(suffix='.couch.test') + +        path = os.path.join(os.path.dirname(__file__), +                            'couchdb.ini.template') +        handle = open(path) +        conf = handle.read() % { +            'tempdir': self.tempdir, +        } +        handle.close() + +        confPath = os.path.join(self.tempdir, 'test.ini') +        handle = open(confPath, 'w') +        handle.write(conf) +        handle.close() + +        # create the dirs from the template +        mkdir_p(os.path.join(self.tempdir, 'lib')) +        mkdir_p(os.path.join(self.tempdir, 'log')) +        args = ['couchdb', '-n', '-a', confPath] +        #null = open('/dev/null', 'w') +        self.process = subprocess.Popen( +            args, env=None, stdout=subprocess.PIPE, stderr=subprocess.PIPE, +            close_fds=True) +        # find port +        logPath = os.path.join(self.tempdir, 'log', 'couch.log') +        while not os.path.exists(logPath): +            if self.process.poll() is not None: +                raise Exception(""" +couchdb exited with code %d. +stdout: +%s +stderr: +%s""" % ( +                    self.process.returncode, self.process.stdout.read(), +                    self.process.stderr.read())) +            time.sleep(0.01) +        while os.stat(logPath).st_size == 0: +            time.sleep(0.01) +        PORT_RE = re.compile( +            'Apache CouchDB has started on http://127.0.0.1:(?P<port>\d+)') + +        handle = open(logPath) +        line = handle.read() +        handle.close() +        m = PORT_RE.search(line) +        if not m: +            self.stop() +            raise Exception("Cannot find port in line %s" % line) +        self.port = int(m.group('port')) + +    def stop(self): +        """ +        Terminate the CouchDB instance. +        """ +        self.process.terminate() +        self.process.communicate() +        shutil.rmtree(self.tempdir) + + +class CouchDBTestCase(unittest.TestCase): +    """ +    TestCase base class for tests against a real CouchDB server. +    """ + +    def setUp(self): +        """ +        Make sure we have a CouchDB instance for a test. +        """ +        self.wrapper = CouchDBWrapper() +        self.wrapper.start() +        #self.db = self.wrapper.db +        unittest.TestCase.setUp(self) + +    def tearDown(self): +        """ +        Stop CouchDB instance for test. +        """ +        self.wrapper.stop() +        unittest.TestCase.tearDown(self) + + +#----------------------------------------------------------------------------- +# The following tests come from `u1db.tests.test_common_backend`. +#----------------------------------------------------------------------------- + +class TestCouchBackendImpl(CouchDBTestCase): + +    def test__allocate_doc_id(self): +        db = couch.CouchDatabase('http://localhost:' + str(self.wrapper.port), +                                 'u1db_tests') +        doc_id1 = db._allocate_doc_id() +        self.assertTrue(doc_id1.startswith('D-')) +        self.assertEqual(34, len(doc_id1)) +        int(doc_id1[len('D-'):], 16) +        self.assertNotEqual(doc_id1, db._allocate_doc_id()) + + +#----------------------------------------------------------------------------- +# The following tests come from `u1db.tests.test_backends`. +#----------------------------------------------------------------------------- + +def make_couch_database_for_test(test, replica_uid): +    port = str(test.wrapper.port) +    return couch.CouchDatabase('http://localhost:' + port, replica_uid, +                               replica_uid=replica_uid or 'test') + + +def copy_couch_database_for_test(test, db): +    port = str(test.wrapper.port) +    new_db = couch.CouchDatabase('http://localhost:' + port, +                                 db._replica_uid + '_copy', +                                 replica_uid=db._replica_uid or 'test') +    gen, docs = db.get_all_docs(include_deleted=True) +    for doc in docs: +        new_db._put_doc(doc) +    new_db._transaction_log = copy.deepcopy(db._transaction_log) +    new_db._conflicts = copy.deepcopy(db._conflicts) +    new_db._other_generations = copy.deepcopy(db._other_generations) +    new_db._indexes = copy.deepcopy(db._indexes) +    new_db._store_u1db_data() +    return new_db + + +def make_document_for_test(test, doc_id, rev, content, has_conflicts=False): +    return SoledadDocument(doc_id, rev, content, has_conflicts=has_conflicts) + + +COUCH_SCENARIOS = [ +    ('couch', {'make_database_for_test': make_couch_database_for_test, +               'copy_database_for_test': copy_couch_database_for_test, +               'make_document_for_test': make_document_for_test, }), +] + + +class CouchTests(test_backends.AllDatabaseTests, CouchDBTestCase): + +    scenarios = COUCH_SCENARIOS + +    def tearDown(self): +        self.db.delete_database() +        test_backends.AllDatabaseTests.tearDown(self) + + +class CouchDatabaseTests(test_backends.LocalDatabaseTests, CouchDBTestCase): + +    scenarios = COUCH_SCENARIOS + +    def tearDown(self): +        self.db.delete_database() +        test_backends.LocalDatabaseTests.tearDown(self) + + +class CouchValidateGenNTransIdTests( +        test_backends.LocalDatabaseValidateGenNTransIdTests, CouchDBTestCase): + +    scenarios = COUCH_SCENARIOS + +    def tearDown(self): +        self.db.delete_database() +        test_backends.LocalDatabaseValidateGenNTransIdTests.tearDown(self) + + +class CouchValidateSourceGenTests( +        test_backends.LocalDatabaseValidateSourceGenTests, CouchDBTestCase): + +    scenarios = COUCH_SCENARIOS + +    def tearDown(self): +        self.db.delete_database() +        test_backends.LocalDatabaseValidateSourceGenTests.tearDown(self) + + +class CouchWithConflictsTests( +        test_backends.LocalDatabaseWithConflictsTests, CouchDBTestCase): + +    scenarios = COUCH_SCENARIOS + +    def tearDown(self): +        self.db.delete_database() +        test_backends.LocalDatabaseWithConflictsTests.tearDown(self) + + +# Notice: the CouchDB backend is currently used for storing encrypted data in +# the server, so indexing makes no sense. Thus, we ignore index testing for +# now. + +class CouchIndexTests(test_backends.DatabaseIndexTests, CouchDBTestCase): + +    scenarios = COUCH_SCENARIOS + +    def tearDown(self): +        self.db.delete_database() +        test_backends.DatabaseIndexTests.tearDown(self) + + +#----------------------------------------------------------------------------- +# The following tests come from `u1db.tests.test_sync`. +#----------------------------------------------------------------------------- + +target_scenarios = [ +    ('local', {'create_db_and_target': test_sync._make_local_db_and_target}), ] + + +simple_doc = tests.simple_doc +nested_doc = tests.nested_doc + + +class CouchDatabaseSyncTargetTests(test_sync.DatabaseSyncTargetTests, +                                   CouchDBTestCase): + +    scenarios = (tests.multiply_scenarios(COUCH_SCENARIOS, target_scenarios)) + +    def tearDown(self): +        self.db.delete_database() +        test_sync.DatabaseSyncTargetTests.tearDown(self) + +    def test_sync_exchange_returns_many_new_docs(self): +        # This test was replicated to allow dictionaries to be compared after +        # JSON expansion (because one dictionary may have many different +        # serialized representations). +        doc = self.db.create_doc_from_json(simple_doc) +        doc2 = self.db.create_doc_from_json(nested_doc) +        self.assertTransactionLog([doc.doc_id, doc2.doc_id], self.db) +        new_gen, _ = self.st.sync_exchange( +            [], 'other-replica', last_known_generation=0, +            last_known_trans_id=None, return_doc_cb=self.receive_doc) +        self.assertTransactionLog([doc.doc_id, doc2.doc_id], self.db) +        self.assertEqual(2, new_gen) +        self.assertEqual( +            [(doc.doc_id, doc.rev, json.loads(simple_doc), 1), +             (doc2.doc_id, doc2.rev, json.loads(nested_doc), 2)], +            [c[:-3] + (json.loads(c[-3]), c[-2]) for c in self.other_changes]) +        if self.whitebox: +            self.assertEqual( +                self.db._last_exchange_log['return'], +                {'last_gen': 2, 'docs': +                 [(doc.doc_id, doc.rev), (doc2.doc_id, doc2.rev)]}) + + +sync_scenarios = [] +for name, scenario in COUCH_SCENARIOS: +    scenario = dict(scenario) +    scenario['do_sync'] = test_sync.sync_via_synchronizer +    sync_scenarios.append((name, scenario)) +    scenario = dict(scenario) + + +class CouchDatabaseSyncTests(test_sync.DatabaseSyncTests, CouchDBTestCase): + +    scenarios = sync_scenarios + +    def setUp(self): +        self.db = None +        self.db1 = None +        self.db2 = None +        self.db3 = None +        test_sync.DatabaseSyncTests.setUp(self) + +    def tearDown(self): +        self.db and self.db.delete_database() +        self.db1 and self.db1.delete_database() +        self.db2 and self.db2.delete_database() +        self.db3 and self.db3.delete_database() +        db = self.create_database('test1_copy', 'source') +        db.delete_database() +        db = self.create_database('test2_copy', 'target') +        db.delete_database() +        db = self.create_database('test3', 'target') +        db.delete_database() +        test_sync.DatabaseSyncTests.tearDown(self) + + +#----------------------------------------------------------------------------- +# The following tests test extra functionality introduced by our backends +#----------------------------------------------------------------------------- + +class CouchDatabaseStorageTests(CouchDBTestCase): + +    def _listify(self, l): +        if type(l) is dict: +            return { +                self._listify(a): self._listify(b) for a, b in l.iteritems()} +        if hasattr(l, '__iter__'): +            return [self._listify(i) for i in l] +        return l + +    def _fetch_u1db_data(self, db): +        cdoc = db._database.get(db.U1DB_DATA_DOC_ID) +        jsonstr = db._database.get_attachment(cdoc, 'u1db_json').getvalue() +        return json.loads(jsonstr) + +    def test_transaction_log_storage_after_put(self): +        db = couch.CouchDatabase('http://localhost:' + str(self.wrapper.port), +                                 'u1db_tests') +        db.create_doc({'simple': 'doc'}) +        content = self._fetch_u1db_data(db) +        self.assertEqual( +            self._listify(db._transaction_log), +            self._listify( +                json.loads(b64decode(content[db.U1DB_TRANSACTION_LOG_KEY])))) + +    def test_conflict_log_storage_after_put_if_newer(self): +        db = couch.CouchDatabase('http://localhost:' + str(self.wrapper.port), +                                 'u1db_tests') +        doc = db.create_doc({'simple': 'doc'}) +        doc.set_json(nested_doc) +        doc.rev = db._replica_uid + ':2' +        db._force_doc_sync_conflict(doc) +        content = self._fetch_u1db_data(db) +        self.assertEqual( +            self._listify(db._conflicts), +            self._listify( +                json.loads(b64decode(content[db.U1DB_CONFLICTS_KEY])))) + +    def test_other_gens_storage_after_set(self): +        db = couch.CouchDatabase('http://localhost:' + str(self.wrapper.port), +                                 'u1db_tests') +        doc = db.create_doc({'simple': 'doc'}) +        db._set_replica_gen_and_trans_id('a', 'b', 'c') +        content = self._fetch_u1db_data(db) +        self.assertEqual( +            self._listify(db._other_generations), +            self._listify( +                json.loads(b64decode(content[db.U1DB_OTHER_GENERATIONS_KEY])))) + +    def test_index_storage_after_create(self): +        db = couch.CouchDatabase('http://localhost:' + str(self.wrapper.port), +                                 'u1db_tests') +        doc = db.create_doc({'name': 'john'}) +        db.create_index('myindex', 'name') +        content = self._fetch_u1db_data(db) +        myind = db._indexes['myindex'] +        index = { +            'myindex': { +                'definition': myind._definition, +                'name': myind._name, +                'values': myind._values, +            } +        } +        self.assertEqual( +            self._listify(index), +            self._listify( +                json.loads(b64decode(content[db.U1DB_INDEXES_KEY])))) + +    def test_index_storage_after_delete(self): +        db = couch.CouchDatabase('http://localhost:' + str(self.wrapper.port), +                                 'u1db_tests') +        doc = db.create_doc({'name': 'john'}) +        db.create_index('myindex', 'name') +        db.create_index('myindex2', 'name') +        db.delete_index('myindex') +        content = self._fetch_u1db_data(db) +        myind = db._indexes['myindex2'] +        index = { +            'myindex2': { +                'definition': myind._definition, +                'name': myind._name, +                'values': myind._values, +            } +        } +        self.assertEqual( +            self._listify(index), +            self._listify( +                json.loads(b64decode(content[db.U1DB_INDEXES_KEY])))) + +    def test_replica_uid_storage_after_db_creation(self): +        db = couch.CouchDatabase('http://localhost:' + str(self.wrapper.port), +                                 'u1db_tests') +        content = self._fetch_u1db_data(db) +        self.assertEqual( +            db._replica_uid, +            b64decode(content[db.U1DB_REPLICA_UID_KEY])) + + +load_tests = tests.load_with_scenarios diff --git a/common/src/leap/soledad/common/tests/test_crypto.py b/common/src/leap/soledad/common/tests/test_crypto.py new file mode 100644 index 00000000..0c7b8254 --- /dev/null +++ b/common/src/leap/soledad/common/tests/test_crypto.py @@ -0,0 +1,292 @@ +# -*- coding: utf-8 -*- +# test_crypto.py +# Copyright (C) 2013 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +""" +Tests for cryptographic related stuff. +""" + +import os +import shutil +import tempfile +import simplejson as json +import hashlib +import binascii + + +from leap.common.testing.basetest import BaseLeapTest +from Crypto import Random + + +from leap.soledad.client import ( +    Soledad, +    crypto, +    target, +) +from leap.soledad.common.document import SoledadDocument +from leap.soledad.common.tests import ( +    BaseSoledadTest, +    KEY_FINGERPRINT, +    PRIVATE_KEY, +) +from leap.soledad.common.tests.u1db_tests import ( +    simple_doc, +    nested_doc, +    TestCaseWithServer, +) + + +class EncryptedSyncTestCase(BaseSoledadTest): +    """ +    Tests that guarantee that data will always be encrypted when syncing. +    """ + +    def test_encrypt_decrypt_json(self): +        """ +        Test encrypting and decrypting documents. +        """ +        simpledoc = {'key': 'val'} +        doc1 = SoledadDocument(doc_id='id') +        doc1.content = simpledoc +        # encrypt doc +        doc1.set_json(target.encrypt_doc(self._soledad._crypto, doc1)) +        # assert content is different and includes keys +        self.assertNotEqual( +            simpledoc, doc1.content, +            'incorrect document encryption') +        self.assertTrue(target.ENC_JSON_KEY in doc1.content) +        self.assertTrue(target.ENC_SCHEME_KEY in doc1.content) +        # decrypt doc +        doc1.set_json(target.decrypt_doc(self._soledad._crypto, doc1)) +        self.assertEqual( +            simpledoc, doc1.content, 'incorrect document encryption') + + +class RecoveryDocumentTestCase(BaseSoledadTest): + +    def test_export_recovery_document_raw(self): +        rd = self._soledad.export_recovery_document() +        secret_id = rd[self._soledad.STORAGE_SECRETS_KEY].items()[0][0] +        secret = rd[self._soledad.STORAGE_SECRETS_KEY][secret_id] +        self.assertEqual(secret_id, self._soledad._secret_id) +        self.assertEqual(secret, self._soledad._secrets[secret_id]) +        self.assertTrue(self._soledad.CIPHER_KEY in secret) +        self.assertTrue(secret[self._soledad.CIPHER_KEY] == 'aes256') +        self.assertTrue(self._soledad.LENGTH_KEY in secret) +        self.assertTrue(self._soledad.SECRET_KEY in secret) + +    def test_import_recovery_document(self): +        rd = self._soledad.export_recovery_document() +        s = self._soledad_instance(user='anotheruser@leap.se') +        s.import_recovery_document(rd) +        s._set_secret_id(self._soledad._secret_id) +        self.assertEqual(self._soledad._uuid, +                         s._uuid, 'Failed setting user uuid.') +        self.assertEqual(self._soledad._get_storage_secret(), +                         s._get_storage_secret(), +                         'Failed settinng secret for symmetric encryption.') + + +class SoledadSecretsTestCase(BaseSoledadTest): + +    def test__gen_secret(self): +        # instantiate and save secret_id +        sol = self._soledad_instance(user='user@leap.se') +        self.assertTrue(len(sol._secrets) == 1) +        secret_id_1 = sol.secret_id +        # assert id is hash of secret +        self.assertTrue( +            secret_id_1 == hashlib.sha256(sol.storage_secret).hexdigest()) +        # generate new secret +        secret_id_2 = sol._gen_secret() +        self.assertTrue(secret_id_1 != secret_id_2) +        # re-instantiate +        sol = self._soledad_instance( +            user='user@leap.se', +            secret_id=secret_id_1) +        # assert ids are valid +        self.assertTrue(len(sol._secrets) == 2) +        self.assertTrue(secret_id_1 in sol._secrets) +        self.assertTrue(secret_id_2 in sol._secrets) +        # assert format of secret 1 +        self.assertTrue(sol.storage_secret is not None) +        self.assertIsInstance(sol.storage_secret, str) +        self.assertTrue(len(sol.storage_secret) == sol.GENERATED_SECRET_LENGTH) +        # assert format of secret 2 +        sol._set_secret_id(secret_id_2) +        self.assertTrue(sol.storage_secret is not None) +        self.assertIsInstance(sol.storage_secret, str) +        self.assertTrue(len(sol.storage_secret) == sol.GENERATED_SECRET_LENGTH) +        # assert id is hash of new secret +        self.assertTrue( +            secret_id_2 == hashlib.sha256(sol.storage_secret).hexdigest()) + +    def test__has_secret(self): +        sol = self._soledad_instance(user='user@leap.se') +        self.assertTrue(sol._has_secret(), "Should have a secret at " +                                           "this point") +        # setting secret id to None should not interfere in the fact we have a +        # secret. +        sol._set_secret_id(None) +        self.assertTrue(sol._has_secret(), "Should have a secret at " +                                           "this point") +        # but not being able to decrypt correctly should +        sol._secrets[sol.secret_id][sol.SECRET_KEY] = None +        self.assertFalse(sol._has_secret()) + + +class MacAuthTestCase(BaseSoledadTest): + +    def test_decrypt_with_wrong_mac_raises(self): +        """ +        Trying to decrypt a document with wrong MAC should raise. +        """ +        simpledoc = {'key': 'val'} +        doc = SoledadDocument(doc_id='id') +        doc.content = simpledoc +        # encrypt doc +        doc.set_json(target.encrypt_doc(self._soledad._crypto, doc)) +        self.assertTrue(target.MAC_KEY in doc.content) +        self.assertTrue(target.MAC_METHOD_KEY in doc.content) +        # mess with MAC +        doc.content[target.MAC_KEY] = '1234567890ABCDEF' +        # try to decrypt doc +        self.assertRaises( +            target.WrongMac, +            target.decrypt_doc, self._soledad._crypto, doc) + +    def test_decrypt_with_unknown_mac_method_raises(self): +        """ +        Trying to decrypt a document with unknown MAC method should raise. +        """ +        simpledoc = {'key': 'val'} +        doc = SoledadDocument(doc_id='id') +        doc.content = simpledoc +        # encrypt doc +        doc.set_json(target.encrypt_doc(self._soledad._crypto, doc)) +        self.assertTrue(target.MAC_KEY in doc.content) +        self.assertTrue(target.MAC_METHOD_KEY in doc.content) +        # mess with MAC method +        doc.content[target.MAC_METHOD_KEY] = 'mymac' +        # try to decrypt doc +        self.assertRaises( +            target.UnknownMacMethod, +            target.decrypt_doc, self._soledad._crypto, doc) + + +class SoledadCryptoAESTestCase(BaseSoledadTest): + +    def test_encrypt_decrypt_sym(self): +        # generate 256-bit key +        key = Random.new().read(32) +        iv, cyphertext = self._soledad._crypto.encrypt_sym( +            'data', key, +            method=crypto.EncryptionMethods.AES_256_CTR) +        self.assertTrue(cyphertext is not None) +        self.assertTrue(cyphertext != '') +        self.assertTrue(cyphertext != 'data') +        plaintext = self._soledad._crypto.decrypt_sym( +            cyphertext, key, iv=iv, +            method=crypto.EncryptionMethods.AES_256_CTR) +        self.assertEqual('data', plaintext) + +    def test_decrypt_with_wrong_iv_fails(self): +        key = Random.new().read(32) +        iv, cyphertext = self._soledad._crypto.encrypt_sym( +            'data', key, +            method=crypto.EncryptionMethods.AES_256_CTR) +        self.assertTrue(cyphertext is not None) +        self.assertTrue(cyphertext != '') +        self.assertTrue(cyphertext != 'data') +        # get a different iv by changing the first byte +        rawiv = binascii.a2b_base64(iv) +        wrongiv = rawiv +        while wrongiv == rawiv: +            wrongiv = os.urandom(1) + rawiv[1:] +        plaintext = self._soledad._crypto.decrypt_sym( +            cyphertext, key, iv=binascii.b2a_base64(wrongiv), +            method=crypto.EncryptionMethods.AES_256_CTR) +        self.assertNotEqual('data', plaintext) + +    def test_decrypt_with_wrong_key_fails(self): +        key = Random.new().read(32) +        iv, cyphertext = self._soledad._crypto.encrypt_sym( +            'data', key, +            method=crypto.EncryptionMethods.AES_256_CTR) +        self.assertTrue(cyphertext is not None) +        self.assertTrue(cyphertext != '') +        self.assertTrue(cyphertext != 'data') +        wrongkey = Random.new().read(32)  # 256-bits key +        # ensure keys are different in case we are extremely lucky +        while wrongkey == key: +            wrongkey = Random.new().read(32) +        plaintext = self._soledad._crypto.decrypt_sym( +            cyphertext, wrongkey, iv=iv, +            method=crypto.EncryptionMethods.AES_256_CTR) +        self.assertNotEqual('data', plaintext) + + +class SoledadCryptoXSalsa20TestCase(BaseSoledadTest): + +    def test_encrypt_decrypt_sym(self): +        # generate 256-bit key +        key = Random.new().read(32) +        iv, cyphertext = self._soledad._crypto.encrypt_sym( +            'data', key, +            method=crypto.EncryptionMethods.XSALSA20) +        self.assertTrue(cyphertext is not None) +        self.assertTrue(cyphertext != '') +        self.assertTrue(cyphertext != 'data') +        plaintext = self._soledad._crypto.decrypt_sym( +            cyphertext, key, iv=iv, +            method=crypto.EncryptionMethods.XSALSA20) +        self.assertEqual('data', plaintext) + +    def test_decrypt_with_wrong_iv_fails(self): +        key = Random.new().read(32) +        iv, cyphertext = self._soledad._crypto.encrypt_sym( +            'data', key, +            method=crypto.EncryptionMethods.XSALSA20) +        self.assertTrue(cyphertext is not None) +        self.assertTrue(cyphertext != '') +        self.assertTrue(cyphertext != 'data') +        # get a different iv by changing the first byte +        rawiv = binascii.a2b_base64(iv) +        wrongiv = rawiv +        while wrongiv == rawiv: +            wrongiv = os.urandom(1) + rawiv[1:] +        plaintext = self._soledad._crypto.decrypt_sym( +            cyphertext, key, iv=binascii.b2a_base64(wrongiv), +            method=crypto.EncryptionMethods.XSALSA20) +        self.assertNotEqual('data', plaintext) + +    def test_decrypt_with_wrong_key_fails(self): +        key = Random.new().read(32) +        iv, cyphertext = self._soledad._crypto.encrypt_sym( +            'data', key, +            method=crypto.EncryptionMethods.XSALSA20) +        self.assertTrue(cyphertext is not None) +        self.assertTrue(cyphertext != '') +        self.assertTrue(cyphertext != 'data') +        wrongkey = Random.new().read(32)  # 256-bits key +        # ensure keys are different in case we are extremely lucky +        while wrongkey == key: +            wrongkey = Random.new().read(32) +        plaintext = self._soledad._crypto.decrypt_sym( +            cyphertext, wrongkey, iv=iv, +            method=crypto.EncryptionMethods.XSALSA20) +        self.assertNotEqual('data', plaintext) diff --git a/common/src/leap/soledad/common/tests/test_server.py b/common/src/leap/soledad/common/tests/test_server.py new file mode 100644 index 00000000..beb7e04d --- /dev/null +++ b/common/src/leap/soledad/common/tests/test_server.py @@ -0,0 +1,376 @@ +# -*- coding: utf-8 -*- +# test_server.py +# Copyright (C) 2013 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +""" +Tests for server-related functionality. +""" + +import os +import tempfile +import simplejson as json +import mock + + +from leap.common.testing.basetest import BaseLeapTest +from leap.soledad.common.couch import ( +    CouchServerState, +    CouchDatabase, +) +from leap.soledad.common.tests.u1db_tests import ( +    TestCaseWithServer, +    simple_doc, +) +from leap.soledad.common.tests.test_couch import CouchDBTestCase +from leap.soledad.common.tests.test_target import ( +    make_token_soledad_app, +    make_leap_document_for_test, +    token_leap_sync_target, +) +from leap.soledad.client import ( +    Soledad, +    target, +) +from leap.soledad.server import SoledadApp +from leap.soledad.server.auth import URLToAuthorization + + +class ServerAuthorizationTestCase(BaseLeapTest): +    """ +    Tests related to Soledad server authorization. +    """ + +    def setUp(self): +        pass + +    def tearDown(self): +        pass + +    def _make_environ(self, path_info, request_method): +        return { +            'PATH_INFO': path_info, +            'REQUEST_METHOD': request_method, +        } + +    def test_verify_action_with_correct_dbnames(self): +        """ +        Test encrypting and decrypting documents. + +        The following table lists the authorized actions among all possible +        u1db remote actions: + +            URL path                      | Authorized actions +            -------------------------------------------------- +            /                             | GET +            /shared-db                    | GET +            /shared-db/docs               | - +            /shared-db/doc/{id}           | GET, PUT, DELETE +            /shared-db/sync-from/{source} | - +            /user-db                      | GET, PUT, DELETE +            /user-db/docs                 | - +            /user-db/doc/{id}             | - +            /user-db/sync-from/{source}   | GET, PUT, POST +        """ +        uuid = 'myuuid' +        authmap = URLToAuthorization( +            uuid, SoledadApp.SHARED_DB_NAME, SoledadApp.USER_DB_PREFIX) +        dbname = authmap._uuid_dbname(uuid) +        # test global auth +        self.assertTrue( +            authmap.is_authorized(self._make_environ('/', 'GET'))) +        # test shared-db database resource auth +        self.assertTrue( +            authmap.is_authorized( +                self._make_environ('/shared', 'GET'))) +        self.assertFalse( +            authmap.is_authorized( +                self._make_environ('/shared', 'PUT'))) +        self.assertFalse( +            authmap.is_authorized( +                self._make_environ('/shared', 'DELETE'))) +        self.assertFalse( +            authmap.is_authorized( +                self._make_environ('/shared', 'POST'))) +        # test shared-db docs resource auth +        self.assertFalse( +            authmap.is_authorized( +                self._make_environ('/shared/docs', 'GET'))) +        self.assertFalse( +            authmap.is_authorized( +                self._make_environ('/shared/docs', 'PUT'))) +        self.assertFalse( +            authmap.is_authorized( +                self._make_environ('/shared/docs', 'DELETE'))) +        self.assertFalse( +            authmap.is_authorized( +                self._make_environ('/shared/docs', 'POST'))) +        # test shared-db doc resource auth +        self.assertTrue( +            authmap.is_authorized( +                self._make_environ('/shared/doc/x', 'GET'))) +        self.assertTrue( +            authmap.is_authorized( +                self._make_environ('/shared/doc/x', 'PUT'))) +        self.assertTrue( +            authmap.is_authorized( +                self._make_environ('/shared/doc/x', 'DELETE'))) +        self.assertFalse( +            authmap.is_authorized( +                self._make_environ('/shared/doc/x', 'POST'))) +        # test shared-db sync resource auth +        self.assertFalse( +            authmap.is_authorized( +                self._make_environ('/shared/sync-from/x', 'GET'))) +        self.assertFalse( +            authmap.is_authorized( +                self._make_environ('/shared/sync-from/x', 'PUT'))) +        self.assertFalse( +            authmap.is_authorized( +                self._make_environ('/shared/sync-from/x', 'DELETE'))) +        self.assertFalse( +            authmap.is_authorized( +                self._make_environ('/shared/sync-from/x', 'POST'))) +        # test user-db database resource auth +        self.assertTrue( +            authmap.is_authorized( +                self._make_environ('/%s' % dbname, 'GET'))) +        self.assertTrue( +            authmap.is_authorized( +                self._make_environ('/%s' % dbname, 'PUT'))) +        self.assertTrue( +            authmap.is_authorized( +                self._make_environ('/%s' % dbname, 'DELETE'))) +        self.assertFalse( +            authmap.is_authorized( +                self._make_environ('/%s' % dbname, 'POST'))) +        # test user-db docs resource auth +        self.assertFalse( +            authmap.is_authorized( +                self._make_environ('/%s/docs' % dbname, 'GET'))) +        self.assertFalse( +            authmap.is_authorized( +                self._make_environ('/%s/docs' % dbname, 'PUT'))) +        self.assertFalse( +            authmap.is_authorized( +                self._make_environ('/%s/docs' % dbname, 'DELETE'))) +        self.assertFalse( +            authmap.is_authorized( +                self._make_environ('/%s/docs' % dbname, 'POST'))) +        # test user-db doc resource auth +        self.assertFalse( +            authmap.is_authorized( +                self._make_environ('/%s/doc/x' % dbname, 'GET'))) +        self.assertFalse( +            authmap.is_authorized( +                self._make_environ('/%s/doc/x' % dbname, 'PUT'))) +        self.assertFalse( +            authmap.is_authorized( +                self._make_environ('/%s/doc/x' % dbname, 'DELETE'))) +        self.assertFalse( +            authmap.is_authorized( +                self._make_environ('/%s/doc/x' % dbname, 'POST'))) +        # test user-db sync resource auth +        self.assertTrue( +            authmap.is_authorized( +                self._make_environ('/%s/sync-from/x' % dbname, 'GET'))) +        self.assertTrue( +            authmap.is_authorized( +                self._make_environ('/%s/sync-from/x' % dbname, 'PUT'))) +        self.assertFalse( +            authmap.is_authorized( +                self._make_environ('/%s/sync-from/x' % dbname, 'DELETE'))) +        self.assertTrue( +            authmap.is_authorized( +                self._make_environ('/%s/sync-from/x' % dbname, 'POST'))) + +    def test_verify_action_with_wrong_dbnames(self): +        """ +        Test if authorization fails for a wrong dbname. +        """ +        uuid = 'myuuid' +        authmap = URLToAuthorization( +            uuid, SoledadApp.SHARED_DB_NAME, SoledadApp.USER_DB_PREFIX) +        dbname = 'somedb' +        # test wrong-db database resource auth +        self.assertFalse( +            authmap.is_authorized( +                self._make_environ('/%s' % dbname, 'GET'))) +        self.assertFalse( +            authmap.is_authorized( +                self._make_environ('/%s' % dbname, 'PUT'))) +        self.assertFalse( +            authmap.is_authorized( +                self._make_environ('/%s' % dbname, 'DELETE'))) +        self.assertFalse( +            authmap.is_authorized( +                self._make_environ('/%s' % dbname, 'POST'))) +        # test wrong-db docs resource auth +        self.assertFalse( +            authmap.is_authorized( +                self._make_environ('/%s/docs' % dbname, 'GET'))) +        self.assertFalse( +            authmap.is_authorized( +                self._make_environ('/%s/docs' % dbname, 'PUT'))) +        self.assertFalse( +            authmap.is_authorized( +                self._make_environ('/%s/docs' % dbname, 'DELETE'))) +        self.assertFalse( +            authmap.is_authorized( +                self._make_environ('/%s/docs' % dbname, 'POST'))) +        # test wrong-db doc resource auth +        self.assertFalse( +            authmap.is_authorized( +                self._make_environ('/%s/doc/x' % dbname, 'GET'))) +        self.assertFalse( +            authmap.is_authorized( +                self._make_environ('/%s/doc/x' % dbname, 'PUT'))) +        self.assertFalse( +            authmap.is_authorized( +                self._make_environ('/%s/doc/x' % dbname, 'DELETE'))) +        self.assertFalse( +            authmap.is_authorized( +                self._make_environ('/%s/doc/x' % dbname, 'POST'))) +        # test wrong-db sync resource auth +        self.assertFalse( +            authmap.is_authorized( +                self._make_environ('/%s/sync-from/x' % dbname, 'GET'))) +        self.assertFalse( +            authmap.is_authorized( +                self._make_environ('/%s/sync-from/x' % dbname, 'PUT'))) +        self.assertFalse( +            authmap.is_authorized( +                self._make_environ('/%s/sync-from/x' % dbname, 'DELETE'))) +        self.assertFalse( +            authmap.is_authorized( +                self._make_environ('/%s/sync-from/x' % dbname, 'POST'))) + + +class EncryptedSyncTestCase( +        CouchDBTestCase, TestCaseWithServer): +    """ +    Tests for encrypted sync using Soledad server backed by a couch database. +    """ + +    @staticmethod +    def make_app_with_state(state): +        return make_token_soledad_app(state) + +    make_document_for_test = make_leap_document_for_test + +    sync_target = token_leap_sync_target + +    def _soledad_instance(self, user='user-uuid', passphrase='123', +                          prefix='', +                          secrets_path=Soledad.STORAGE_SECRETS_FILE_NAME, +                          local_db_path='soledad.u1db', server_url='', +                          cert_file=None, auth_token=None, secret_id=None): +        """ +        Instantiate Soledad. +        """ + +        # this callback ensures we save a document which is sent to the shared +        # db. +        def _put_doc_side_effect(doc): +            self._doc_put = doc + +        # we need a mocked shared db or else Soledad will try to access the +        # network to find if there are uploaded secrets. +        class MockSharedDB(object): + +            get_doc = mock.Mock(return_value=None) +            put_doc = mock.Mock(side_effect=_put_doc_side_effect) + +            def __call__(self): +                return self + +        Soledad._shared_db = MockSharedDB() +        return Soledad( +            user, +            passphrase, +            secrets_path=os.path.join(self.tempdir, prefix, secrets_path), +            local_db_path=os.path.join( +                self.tempdir, prefix, local_db_path), +            server_url=server_url, +            cert_file=cert_file, +            auth_token=auth_token, +            secret_id=secret_id) + +    def make_app(self): +        self.request_state = CouchServerState(self._couch_url) +        return self.make_app_with_state(self.request_state) + +    def setUp(self): +        TestCaseWithServer.setUp(self) +        CouchDBTestCase.setUp(self) +        self.tempdir = tempfile.mkdtemp(prefix="leap_tests-") +        self._couch_url = 'http://localhost:' + str(self.wrapper.port) + +    def tearDown(self): +        CouchDBTestCase.tearDown(self) +        TestCaseWithServer.tearDown(self) + +    def test_encrypted_sym_sync(self): +        """ +        Test the complete syncing chain between two soledad dbs using a +        Soledad server backed by a couch database. +        """ +        self.startServer() +        # instantiate soledad and create a document +        sol1 = self._soledad_instance( +            # token is verified in test_target.make_token_soledad_app +            auth_token='auth-token' +        ) +        _, doclist = sol1.get_all_docs() +        self.assertEqual([], doclist) +        doc1 = sol1.create_doc(json.loads(simple_doc)) +        # sync with server +        sol1._server_url = self.getURL() +        sol1.sync() +        # assert doc was sent to couch db +        db = CouchDatabase( +            self._couch_url, +            # the name of the user database is "user-<uuid>". +            'user-user-uuid', +        ) +        _, doclist = db.get_all_docs() +        self.assertEqual(1, len(doclist)) +        couchdoc = doclist[0] +        # assert document structure in couch server +        self.assertEqual(doc1.doc_id, couchdoc.doc_id) +        self.assertEqual(doc1.rev, couchdoc.rev) +        self.assertEqual(6, len(couchdoc.content)) +        self.assertTrue(target.ENC_JSON_KEY in couchdoc.content) +        self.assertTrue(target.ENC_SCHEME_KEY in couchdoc.content) +        self.assertTrue(target.ENC_METHOD_KEY in couchdoc.content) +        self.assertTrue(target.ENC_IV_KEY in couchdoc.content) +        self.assertTrue(target.MAC_KEY in couchdoc.content) +        self.assertTrue(target.MAC_METHOD_KEY in couchdoc.content) +        # instantiate soledad with empty db, but with same secrets path +        sol2 = self._soledad_instance(prefix='x', auth_token='auth-token') +        _, doclist = sol2.get_all_docs() +        self.assertEqual([], doclist) +        sol2._secrets_path = sol1.secrets_path +        sol2._load_secrets() +        sol2._set_secret_id(sol1._secret_id) +        # sync the new instance +        sol2._server_url = self.getURL() +        sol2.sync() +        _, doclist = sol2.get_all_docs() +        self.assertEqual(1, len(doclist)) +        doc2 = doclist[0] +        # assert incoming doc is equal to the first sent doc +        self.assertEqual(doc1, doc2) diff --git a/common/src/leap/soledad/common/tests/test_soledad.py b/common/src/leap/soledad/common/tests/test_soledad.py new file mode 100644 index 00000000..0b753647 --- /dev/null +++ b/common/src/leap/soledad/common/tests/test_soledad.py @@ -0,0 +1,342 @@ +# -*- coding: utf-8 -*- +# test_soledad.py +# Copyright (C) 2013 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +""" +Tests for general Soledad functionality. +""" + + +import os +from mock import Mock + + +from pysqlcipher.dbapi2 import DatabaseError +from leap.common.events import events_pb2 as proto +from leap.soledad.common.tests import ( +    BaseSoledadTest, +    ADDRESS, +) +from leap import soledad +from leap.soledad.common.document import SoledadDocument +from leap.soledad.client import Soledad, PassphraseTooShort +from leap.soledad.client.crypto import SoledadCrypto +from leap.soledad.client.shared_db import SoledadSharedDatabase +from leap.soledad.client.target import SoledadSyncTarget + + +class AuxMethodsTestCase(BaseSoledadTest): + +    def test__init_dirs(self): +        sol = self._soledad_instance(prefix='_init_dirs') +        sol._init_dirs() +        local_db_dir = os.path.dirname(sol.local_db_path) +        secrets_path = os.path.dirname(sol.secrets_path) +        self.assertTrue(os.path.isdir(local_db_dir)) +        self.assertTrue(os.path.isdir(secrets_path)) + +    def test__init_db(self): +        sol = self._soledad_instance() +        sol._init_dirs() +        sol._crypto = SoledadCrypto(sol) +        #self._soledad._gpg.import_keys(PUBLIC_KEY) +        if not sol._has_secret(): +            sol._gen_secret() +        sol._load_secrets() +        sol._init_db() +        from leap.soledad.client.sqlcipher import SQLCipherDatabase +        self.assertIsInstance(sol._db, SQLCipherDatabase) + +    def test__init_config_defaults(self): +        """ +        Test if configuration defaults point to the correct place. +        """ + +        class SoledadMock(Soledad): + +            def __init__(self): +                pass + +        # instantiate without initializing so we just test _init_config() +        sol = SoledadMock() +        Soledad._init_config(sol, None, None, '') +        # assert value of secrets_path +        self.assertEquals( +            os.path.join( +                sol.DEFAULT_PREFIX, Soledad.STORAGE_SECRETS_FILE_NAME), +            sol.secrets_path) +        # assert value of local_db_path +        self.assertEquals( +            os.path.join(sol.DEFAULT_PREFIX, 'soledad.u1db'), +            sol.local_db_path) + +    def test__init_config_from_params(self): +        """ +        Test if configuration is correctly read from file. +        """ +        sol = self._soledad_instance( +            'leap@leap.se', +            passphrase='123', +            secrets_path='value_3', +            local_db_path='value_2', +            server_url='value_1', +            cert_file=None) +        self.assertEqual( +            os.path.join(self.tempdir, 'value_3'), +            sol.secrets_path) +        self.assertEqual( +            os.path.join(self.tempdir, 'value_2'), +            sol.local_db_path) +        self.assertEqual('value_1', sol.server_url) + +    def test_change_passphrase(self): +        """ +        Test if passphrase can be changed. +        """ +        sol = self._soledad_instance( +            'leap@leap.se', +            passphrase='123', +            prefix=self.rand_prefix, +        ) +        doc = sol.create_doc({'simple': 'doc'}) +        doc_id = doc.doc_id + +        # change the passphrase +        sol.change_passphrase('654321') + +        self.assertRaises( +            DatabaseError, +            self._soledad_instance, 'leap@leap.se', +            passphrase='123', +            prefix=self.rand_prefix) + +        # use new passphrase and retrieve doc +        sol2 = self._soledad_instance( +            'leap@leap.se', +            passphrase='654321', +            prefix=self.rand_prefix) +        doc2 = sol2.get_doc(doc_id) +        self.assertEqual(doc, doc2) + +    def test_change_passphrase_with_short_passphrase_raises(self): +        """ +        Test if attempt to change passphrase passing a short passphrase +        raises. +        """ +        sol = self._soledad_instance( +            'leap@leap.se', +            passphrase='123') +        # check that soledad complains about new passphrase length +        self.assertRaises( +            PassphraseTooShort, +            sol.change_passphrase, '54321') + +    def test_get_passphrase(self): +        """ +        Assert passphrase getter works fine. +        """ +        sol = self._soledad_instance() +        self.assertEqual('123', sol.passphrase) + + +class SoledadSharedDBTestCase(BaseSoledadTest): +    """ +    These tests ensure the functionalities of the shared recovery database. +    """ + +    def setUp(self): +        BaseSoledadTest.setUp(self) +        self._shared_db = SoledadSharedDatabase( +            'https://provider/', SoledadDocument, None) + +    def test__get_secrets_from_shared_db(self): +        """ +        Ensure the shared db is queried with the correct doc_id. +        """ +        doc_id = self._soledad._uuid_hash() +        self._soledad._get_secrets_from_shared_db() +        self.assertTrue( +            self._soledad._shared_db().get_doc.assert_called_with( +                doc_id) is None, +            'Wrong doc_id when fetching recovery document.') + +    def test__put_secrets_in_shared_db(self): +        """ +        Ensure recovery document is put into shared recover db. +        """ +        doc_id = self._soledad._uuid_hash() +        self._soledad._put_secrets_in_shared_db() +        self.assertTrue( +            self._soledad._shared_db().get_doc.assert_called_with( +                doc_id) is None, +            'Wrong doc_id when fetching recovery document.') +        self.assertTrue( +            self._soledad._shared_db.put_doc.assert_called_with( +                self._doc_put) is None, +            'Wrong document when putting recovery document.') +        self.assertTrue( +            self._doc_put.doc_id == doc_id, +            'Wrong doc_id when putting recovery document.') + + +class SoledadSignalingTestCase(BaseSoledadTest): +    """ +    These tests ensure signals are correctly emmited by Soledad. +    """ + +    EVENTS_SERVER_PORT = 8090 + +    def setUp(self): +        BaseSoledadTest.setUp(self) +        # mock signaling +        soledad.client.signal = Mock() + +    def tearDown(self): +        pass + +    def _pop_mock_call(self, mocked): +        mocked.call_args_list.pop() +        mocked.mock_calls.pop() +        mocked.call_args = mocked.call_args_list[-1] + +    def test_stage2_bootstrap_signals(self): +        """ +        Test that a fresh soledad emits all bootstrap signals. +        """ +        soledad.client.signal.reset_mock() +        # get a fresh instance so it emits all bootstrap signals +        sol = self._soledad_instance( +            secrets_path='alternative.json', +            local_db_path='alternative.u1db') +        # reverse call order so we can verify in the order the signals were +        # expected +        soledad.client.signal.mock_calls.reverse() +        soledad.client.signal.call_args = \ +            soledad.client.signal.call_args_list[0] +        soledad.client.signal.call_args_list.reverse() +        # assert signals +        soledad.client.signal.assert_called_with( +            proto.SOLEDAD_DOWNLOADING_KEYS, +            ADDRESS, +        ) +        self._pop_mock_call(soledad.client.signal) +        soledad.client.signal.assert_called_with( +            proto.SOLEDAD_DONE_DOWNLOADING_KEYS, +            ADDRESS, +        ) +        self._pop_mock_call(soledad.client.signal) +        soledad.client.signal.assert_called_with( +            proto.SOLEDAD_CREATING_KEYS, +            ADDRESS, +        ) +        self._pop_mock_call(soledad.client.signal) +        soledad.client.signal.assert_called_with( +            proto.SOLEDAD_DONE_CREATING_KEYS, +            ADDRESS, +        ) +        self._pop_mock_call(soledad.client.signal) +        soledad.client.signal.assert_called_with( +            proto.SOLEDAD_DOWNLOADING_KEYS, +            ADDRESS, +        ) +        self._pop_mock_call(soledad.client.signal) +        soledad.client.signal.assert_called_with( +            proto.SOLEDAD_DONE_DOWNLOADING_KEYS, +            ADDRESS, +        ) +        self._pop_mock_call(soledad.client.signal) +        soledad.client.signal.assert_called_with( +            proto.SOLEDAD_UPLOADING_KEYS, +            ADDRESS, +        ) +        self._pop_mock_call(soledad.client.signal) +        soledad.client.signal.assert_called_with( +            proto.SOLEDAD_DONE_UPLOADING_KEYS, +            ADDRESS, +        ) + +    def test_stage1_bootstrap_signals(self): +        """ +        Test that an existent soledad emits some of the bootstrap signals. +        """ +        soledad.client.signal.reset_mock() +        # get an existent instance so it emits only some of bootstrap signals +        sol = self._soledad_instance() +        # reverse call order so we can verify in the order the signals were +        # expected +        soledad.client.signal.mock_calls.reverse() +        soledad.client.signal.call_args = \ +            soledad.client.signal.call_args_list[0] +        soledad.client.signal.call_args_list.reverse() +        # assert signals +        soledad.client.signal.assert_called_with( +            proto.SOLEDAD_DOWNLOADING_KEYS, +            ADDRESS, +        ) +        self._pop_mock_call(soledad.client.signal) +        soledad.client.signal.assert_called_with( +            proto.SOLEDAD_DONE_DOWNLOADING_KEYS, +            ADDRESS, +        ) +        self._pop_mock_call(soledad.client.signal) +        soledad.client.signal.assert_called_with( +            proto.SOLEDAD_UPLOADING_KEYS, +            ADDRESS, +        ) +        self._pop_mock_call(soledad.client.signal) +        soledad.client.signal.assert_called_with( +            proto.SOLEDAD_DONE_UPLOADING_KEYS, +            ADDRESS, +        ) + +    def test_sync_signals(self): +        """ +        Test Soledad emits SOLEDAD_CREATING_KEYS signal. +        """ +        soledad.client.signal.reset_mock() +        # get a fresh instance so it emits all bootstrap signals +        sol = self._soledad_instance() +        # mock the actual db sync so soledad does not try to connect to the +        # server +        sol._db.sync = Mock() +        # do the sync +        sol.sync() +        # assert the signal has been emitted +        soledad.client.signal.assert_called_with( +            proto.SOLEDAD_DONE_DATA_SYNC, +            ADDRESS, +        ) + +    def test_need_sync_signals(self): +        """ +        Test Soledad emits SOLEDAD_CREATING_KEYS signal. +        """ +        soledad.client.signal.reset_mock() +        sol = self._soledad_instance() +        # mock the sync target +        old_get_sync_info = SoledadSyncTarget.get_sync_info +        SoledadSyncTarget.get_sync_info = Mock(return_value=[0, 0, 0, 0, 2]) +        # mock our generation so soledad thinks there's new data to sync +        sol._db._get_generation = Mock(return_value=1) +        # check for new data to sync +        sol.need_sync('http://provider/userdb') +        # assert the signal has been emitted +        soledad.client.signal.assert_called_with( +            proto.SOLEDAD_NEW_DATA_TO_SYNC, +            ADDRESS, +        ) +        SoledadSyncTarget.get_sync_info = old_get_sync_info diff --git a/common/src/leap/soledad/common/tests/test_sqlcipher.py b/common/src/leap/soledad/common/tests/test_sqlcipher.py new file mode 100644 index 00000000..66a673b6 --- /dev/null +++ b/common/src/leap/soledad/common/tests/test_sqlcipher.py @@ -0,0 +1,825 @@ +# -*- coding: utf-8 -*- +# test_sqlcipher.py +# Copyright (C) 2013 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +""" +Test sqlcipher backend internals. +""" + + +import os +import time +import unittest +import simplejson as json +import threading + + +from pysqlcipher import dbapi2 +from StringIO import StringIO + + +# u1db stuff. +from u1db import ( +    errors, +    query_parser, +    sync, +    vectorclock, +) +from u1db.backends.sqlite_backend import SQLitePartialExpandDatabase + + +# soledad stuff. +from leap.soledad.common.document import SoledadDocument +from leap.soledad.client.sqlcipher import ( +    SQLCipherDatabase, +    DatabaseIsNotEncrypted, +    open as u1db_open, +) +from leap.soledad.common.crypto import ( +    EncryptionSchemes, +    ENC_JSON_KEY, +    ENC_SCHEME_KEY, +) +from leap.soledad.client.target import decrypt_doc + + +# u1db tests stuff. +from leap.soledad.common.tests import u1db_tests as tests, BaseSoledadTest +from leap.soledad.common.tests.u1db_tests import test_sqlite_backend +from leap.soledad.common.tests.u1db_tests import test_backends +from leap.soledad.common.tests.u1db_tests import test_open +from leap.soledad.common.tests.u1db_tests import test_sync +from leap.soledad.client.target import SoledadSyncTarget +from leap.common.testing.basetest import BaseLeapTest + +PASSWORD = '123456' + + +#----------------------------------------------------------------------------- +# The following tests come from `u1db.tests.test_common_backend`. +#----------------------------------------------------------------------------- + +class TestSQLCipherBackendImpl(tests.TestCase): + +    def test__allocate_doc_id(self): +        db = SQLCipherDatabase(':memory:', PASSWORD) +        doc_id1 = db._allocate_doc_id() +        self.assertTrue(doc_id1.startswith('D-')) +        self.assertEqual(34, len(doc_id1)) +        int(doc_id1[len('D-'):], 16) +        self.assertNotEqual(doc_id1, db._allocate_doc_id()) + + +#----------------------------------------------------------------------------- +# The following tests come from `u1db.tests.test_backends`. +#----------------------------------------------------------------------------- + +def make_sqlcipher_database_for_test(test, replica_uid): +    db = SQLCipherDatabase(':memory:', PASSWORD) +    db._set_replica_uid(replica_uid) +    return db + + +def copy_sqlcipher_database_for_test(test, db): +    # DO NOT COPY OR REUSE THIS CODE OUTSIDE TESTS: COPYING U1DB DATABASES IS +    # THE WRONG THING TO DO, THE ONLY REASON WE DO SO HERE IS TO TEST THAT WE +    # CORRECTLY DETECT IT HAPPENING SO THAT WE CAN RAISE ERRORS RATHER THAN +    # CORRUPT USER DATA. USE SYNC INSTEAD, OR WE WILL SEND NINJA TO YOUR +    # HOUSE. +    new_db = SQLCipherDatabase(':memory:', PASSWORD) +    tmpfile = StringIO() +    for line in db._db_handle.iterdump(): +        if not 'sqlite_sequence' in line:  # work around bug in iterdump +            tmpfile.write('%s\n' % line) +    tmpfile.seek(0) +    new_db._db_handle = dbapi2.connect(':memory:') +    new_db._db_handle.cursor().executescript(tmpfile.read()) +    new_db._db_handle.commit() +    new_db._set_replica_uid(db._replica_uid) +    new_db._factory = db._factory +    return new_db + + +def make_document_for_test(test, doc_id, rev, content, has_conflicts=False): +    return SoledadDocument(doc_id, rev, content, has_conflicts=has_conflicts) + + +SQLCIPHER_SCENARIOS = [ +    ('sqlcipher', {'make_database_for_test': make_sqlcipher_database_for_test, +                   'copy_database_for_test': copy_sqlcipher_database_for_test, +                   'make_document_for_test': make_document_for_test, }), +] + + +class SQLCipherTests(test_backends.AllDatabaseTests): +    scenarios = SQLCIPHER_SCENARIOS + + +class SQLCipherDatabaseTests(test_backends.LocalDatabaseTests): +    scenarios = SQLCIPHER_SCENARIOS + + +class SQLCipherValidateGenNTransIdTests( +        test_backends.LocalDatabaseValidateGenNTransIdTests): +    scenarios = SQLCIPHER_SCENARIOS + + +class SQLCipherValidateSourceGenTests( +        test_backends.LocalDatabaseValidateSourceGenTests): +    scenarios = SQLCIPHER_SCENARIOS + + +class SQLCipherWithConflictsTests( +        test_backends.LocalDatabaseWithConflictsTests): +    scenarios = SQLCIPHER_SCENARIOS + + +class SQLCipherIndexTests(test_backends.DatabaseIndexTests): +    scenarios = SQLCIPHER_SCENARIOS + + +load_tests = tests.load_with_scenarios + + +#----------------------------------------------------------------------------- +# The following tests come from `u1db.tests.test_sqlite_backend`. +#----------------------------------------------------------------------------- + +class TestSQLCipherDatabase(test_sqlite_backend.TestSQLiteDatabase): + +    def test_atomic_initialize(self): +        tmpdir = self.createTempDir() +        dbname = os.path.join(tmpdir, 'atomic.db') + +        t2 = None  # will be a thread + +        class SQLCipherDatabaseTesting(SQLCipherDatabase): +            _index_storage_value = "testing" + +            def __init__(self, dbname, ntry): +                self._try = ntry +                self._is_initialized_invocations = 0 +                SQLCipherDatabase.__init__(self, dbname, PASSWORD) + +            def _is_initialized(self, c): +                res = \ +                    SQLCipherDatabase._is_initialized(self, c) +                if self._try == 1: +                    self._is_initialized_invocations += 1 +                    if self._is_initialized_invocations == 2: +                        t2.start() +                        # hard to do better and have a generic test +                        time.sleep(0.05) +                return res + +        outcome2 = [] + +        def second_try(): +            try: +                db2 = SQLCipherDatabaseTesting(dbname, 2) +            except Exception, e: +                outcome2.append(e) +            else: +                outcome2.append(db2) + +        t2 = threading.Thread(target=second_try) +        db1 = SQLCipherDatabaseTesting(dbname, 1) +        t2.join() + +        self.assertIsInstance(outcome2[0], SQLCipherDatabaseTesting) +        db2 = outcome2[0] +        self.assertTrue(db2._is_initialized(db1._get_sqlite_handle().cursor())) + + +class TestAlternativeDocument(SoledadDocument): +    """A (not very) alternative implementation of Document.""" + + +class TestSQLCipherPartialExpandDatabase( +        test_sqlite_backend.TestSQLitePartialExpandDatabase): + +    # The following tests had to be cloned from u1db because they all +    # instantiate the backend directly, so we need to change that in order to +    # our backend be instantiated in place. + +    def setUp(self): +        test_sqlite_backend.TestSQLitePartialExpandDatabase.setUp(self) +        self.db = SQLCipherDatabase(':memory:', PASSWORD) +        self.db._set_replica_uid('test') + +    def test_default_replica_uid(self): +        self.db = SQLCipherDatabase(':memory:', PASSWORD) +        self.assertIsNot(None, self.db._replica_uid) +        self.assertEqual(32, len(self.db._replica_uid)) +        int(self.db._replica_uid, 16) + +    def test__parse_index(self): +        self.db = SQLCipherDatabase(':memory:', PASSWORD) +        g = self.db._parse_index_definition('fieldname') +        self.assertIsInstance(g, query_parser.ExtractField) +        self.assertEqual(['fieldname'], g.field) + +    def test__update_indexes(self): +        self.db = SQLCipherDatabase(':memory:', PASSWORD) +        g = self.db._parse_index_definition('fieldname') +        c = self.db._get_sqlite_handle().cursor() +        self.db._update_indexes('doc-id', {'fieldname': 'val'}, +                                [('fieldname', g)], c) +        c.execute('SELECT doc_id, field_name, value FROM document_fields') +        self.assertEqual([('doc-id', 'fieldname', 'val')], +                         c.fetchall()) + +    def test__set_replica_uid(self): +        # Start from scratch, so that replica_uid isn't set. +        self.db = SQLCipherDatabase(':memory:', PASSWORD) +        self.assertIsNot(None, self.db._real_replica_uid) +        self.assertIsNot(None, self.db._replica_uid) +        self.db._set_replica_uid('foo') +        c = self.db._get_sqlite_handle().cursor() +        c.execute("SELECT value FROM u1db_config WHERE name='replica_uid'") +        self.assertEqual(('foo',), c.fetchone()) +        self.assertEqual('foo', self.db._real_replica_uid) +        self.assertEqual('foo', self.db._replica_uid) +        self.db._close_sqlite_handle() +        self.assertEqual('foo', self.db._replica_uid) + +    def test__open_database(self): +        temp_dir = self.createTempDir(prefix='u1db-test-') +        path = temp_dir + '/test.sqlite' +        SQLCipherDatabase(path, PASSWORD) +        db2 = SQLCipherDatabase._open_database(path, PASSWORD) +        self.assertIsInstance(db2, SQLCipherDatabase) + +    def test__open_database_with_factory(self): +        temp_dir = self.createTempDir(prefix='u1db-test-') +        path = temp_dir + '/test.sqlite' +        SQLCipherDatabase(path, PASSWORD) +        db2 = SQLCipherDatabase._open_database( +            path, PASSWORD, +            document_factory=TestAlternativeDocument) +        doc = db2.create_doc({}) +        self.assertTrue(isinstance(doc, SoledadDocument)) + +    def test__open_database_non_existent(self): +        temp_dir = self.createTempDir(prefix='u1db-test-') +        path = temp_dir + '/non-existent.sqlite' +        self.assertRaises(errors.DatabaseDoesNotExist, +                          SQLCipherDatabase._open_database, +                          path, PASSWORD) + +    def test__open_database_during_init(self): +        temp_dir = self.createTempDir(prefix='u1db-test-') +        path = temp_dir + '/initialised.db' +        db = SQLCipherDatabase.__new__( +            SQLCipherDatabase) +        db._db_handle = dbapi2.connect(path)  # db is there but not yet init-ed +        c = db._db_handle.cursor() +        c.execute('PRAGMA key="%s"' % PASSWORD) +        self.addCleanup(db.close) +        observed = [] + +        class SQLiteDatabaseTesting(SQLCipherDatabase): +            WAIT_FOR_PARALLEL_INIT_HALF_INTERVAL = 0.1 + +            @classmethod +            def _which_index_storage(cls, c): +                res = SQLCipherDatabase._which_index_storage(c) +                db._ensure_schema()  # init db +                observed.append(res[0]) +                return res + +        db2 = SQLiteDatabaseTesting._open_database(path, PASSWORD) +        self.addCleanup(db2.close) +        self.assertIsInstance(db2, SQLCipherDatabase) +        self.assertEqual( +            [None, +             SQLCipherDatabase._index_storage_value], +            observed) + +    def test__open_database_invalid(self): +        class SQLiteDatabaseTesting(SQLCipherDatabase): +            WAIT_FOR_PARALLEL_INIT_HALF_INTERVAL = 0.1 +        temp_dir = self.createTempDir(prefix='u1db-test-') +        path1 = temp_dir + '/invalid1.db' +        with open(path1, 'wb') as f: +            f.write("") +        self.assertRaises(dbapi2.OperationalError, +                          SQLiteDatabaseTesting._open_database, path1, +                          PASSWORD) +        with open(path1, 'wb') as f: +            f.write("invalid") +        self.assertRaises(dbapi2.DatabaseError, +                          SQLiteDatabaseTesting._open_database, path1, +                          PASSWORD) + +    def test_open_database_existing(self): +        temp_dir = self.createTempDir(prefix='u1db-test-') +        path = temp_dir + '/existing.sqlite' +        SQLCipherDatabase(path, PASSWORD) +        db2 = SQLCipherDatabase.open_database(path, PASSWORD, create=False) +        self.assertIsInstance(db2, SQLCipherDatabase) + +    def test_open_database_with_factory(self): +        temp_dir = self.createTempDir(prefix='u1db-test-') +        path = temp_dir + '/existing.sqlite' +        SQLCipherDatabase(path, PASSWORD) +        db2 = SQLCipherDatabase.open_database( +            path, PASSWORD, create=False, +            document_factory=TestAlternativeDocument) +        doc = db2.create_doc({}) +        self.assertTrue(isinstance(doc, SoledadDocument)) + +    def test_open_database_create(self): +        temp_dir = self.createTempDir(prefix='u1db-test-') +        path = temp_dir + '/new.sqlite' +        SQLCipherDatabase.open_database(path, PASSWORD, create=True) +        db2 = SQLCipherDatabase.open_database(path, PASSWORD, create=False) +        self.assertIsInstance(db2, SQLCipherDatabase) + +    def test_create_database_initializes_schema(self): +        # This test had to be cloned because our implementation of SQLCipher +        # backend is referenced with an index_storage_value that includes the +        # word "encrypted". See u1db's sqlite_backend and our +        # sqlcipher_backend for reference. +        raw_db = self.db._get_sqlite_handle() +        c = raw_db.cursor() +        c.execute("SELECT * FROM u1db_config") +        config = dict([(r[0], r[1]) for r in c.fetchall()]) +        self.assertEqual({'sql_schema': '0', 'replica_uid': 'test', +                          'index_storage': 'expand referenced encrypted'}, +                         config) + +    def test_store_syncable(self): +        doc = self.db.create_doc_from_json(tests.simple_doc) +        # assert that docs are syncable by default +        self.assertEqual(True, doc.syncable) +        # assert that we can store syncable = False +        doc.syncable = False +        self.db.put_doc(doc) +        self.assertEqual(False, self.db.get_doc(doc.doc_id).syncable) +        # assert that we can store syncable = True +        doc.syncable = True +        self.db.put_doc(doc) +        self.assertEqual(True, self.db.get_doc(doc.doc_id).syncable) + + +#----------------------------------------------------------------------------- +# The following tests come from `u1db.tests.test_open`. +#----------------------------------------------------------------------------- + +class SQLCipherOpen(test_open.TestU1DBOpen): + +    def test_open_no_create(self): +        self.assertRaises(errors.DatabaseDoesNotExist, +                          u1db_open, self.db_path, +                          password=PASSWORD, +                          create=False) +        self.assertFalse(os.path.exists(self.db_path)) + +    def test_open_create(self): +        db = u1db_open(self.db_path, password=PASSWORD, create=True) +        self.addCleanup(db.close) +        self.assertTrue(os.path.exists(self.db_path)) +        self.assertIsInstance(db, SQLCipherDatabase) + +    def test_open_with_factory(self): +        db = u1db_open(self.db_path, password=PASSWORD, create=True, +                       document_factory=TestAlternativeDocument) +        self.addCleanup(db.close) +        doc = db.create_doc({}) +        self.assertTrue(isinstance(doc, SoledadDocument)) + +    def test_open_existing(self): +        db = SQLCipherDatabase(self.db_path, PASSWORD) +        self.addCleanup(db.close) +        doc = db.create_doc_from_json(tests.simple_doc) +        # Even though create=True, we shouldn't wipe the db +        db2 = u1db_open(self.db_path, password=PASSWORD, create=True) +        self.addCleanup(db2.close) +        doc2 = db2.get_doc(doc.doc_id) +        self.assertEqual(doc, doc2) + +    def test_open_existing_no_create(self): +        db = SQLCipherDatabase(self.db_path, PASSWORD) +        self.addCleanup(db.close) +        db2 = u1db_open(self.db_path, password=PASSWORD, create=False) +        self.addCleanup(db2.close) +        self.assertIsInstance(db2, SQLCipherDatabase) + + +#----------------------------------------------------------------------------- +# The following tests come from `u1db.tests.test_sync`. +#----------------------------------------------------------------------------- + +sync_scenarios = [] +for name, scenario in SQLCIPHER_SCENARIOS: +    scenario = dict(scenario) +    scenario['do_sync'] = test_sync.sync_via_synchronizer +    sync_scenarios.append((name, scenario)) +    scenario = dict(scenario) + + +def sync_via_synchronizer_and_leap(test, db_source, db_target, +                                   trace_hook=None, trace_hook_shallow=None): +    if trace_hook: +        test.skipTest("full trace hook unsupported over http") +    path = test._http_at[db_target] +    target = SoledadSyncTarget.connect( +        test.getURL(path), test._soledad._crypto) +    target.set_token_credentials('user-uuid', 'auth-token') +    if trace_hook_shallow: +        target._set_trace_hook_shallow(trace_hook_shallow) +    return sync.Synchronizer(db_source, target).sync() + + +sync_scenarios.append(('pyleap', { +    'make_database_for_test': test_sync.make_database_for_http_test, +    'copy_database_for_test': test_sync.copy_database_for_http_test, +    'make_document_for_test': make_document_for_test, +    'make_app_with_state': tests.test_remote_sync_target.make_http_app, +    'do_sync': sync_via_synchronizer_and_leap, +})) + + +class SQLCipherDatabaseSyncTests( +        test_sync.DatabaseSyncTests, BaseSoledadTest): +    """ +    Test for succesfull sync between SQLCipher and LeapBackend. + +    Some of the tests in this class had to be adapted because the remote +    backend always receive encrypted content, and so it can not rely on +    document's content comparison to try to autoresolve conflicts. +    """ + +    scenarios = sync_scenarios + +    def setUp(self): +        test_sync.DatabaseSyncTests.setUp(self) +        BaseSoledadTest.setUp(self) + +    def tearDown(self): +        test_sync.DatabaseSyncTests.tearDown(self) +        BaseSoledadTest.tearDown(self) + +    def test_sync_autoresolves(self): +        """ +        Test for sync autoresolve remote. + +        This test was adapted because the remote database receives encrypted +        content and so it can't compare documents contents to autoresolve. +        """ +        # The remote database can't autoresolve conflicts based on magic +        # content convergence, so we modify this test to leave the possibility +        # of the remode document ending up in conflicted state. +        self.db1 = self.create_database('test1', 'source') +        self.db2 = self.create_database('test2', 'target') +        doc1 = self.db1.create_doc_from_json(tests.simple_doc, doc_id='doc') +        rev1 = doc1.rev +        doc2 = self.db2.create_doc_from_json(tests.simple_doc, doc_id='doc') +        rev2 = doc2.rev +        self.sync(self.db1, self.db2) +        doc = self.db1.get_doc('doc') +        self.assertFalse(doc.has_conflicts) +        # if remote content is in conflicted state, then document revisions +        # will be different. +        #self.assertEqual(doc.rev, self.db2.get_doc('doc').rev) +        v = vectorclock.VectorClockRev(doc.rev) +        self.assertTrue(v.is_newer(vectorclock.VectorClockRev(rev1))) +        self.assertTrue(v.is_newer(vectorclock.VectorClockRev(rev2))) + +    def test_sync_autoresolves_moar(self): +        """ +        Test for sync autoresolve local. + +        This test was adapted to decrypt remote content before assert. +        """ +        # here we test that when a database that has a conflicted document is +        # the source of a sync, and the target database has a revision of the +        # conflicted document that is newer than the source database's, and +        # that target's database's document's content is the same as the +        # source's document's conflict's, the source's document's conflict gets +        # autoresolved, and the source's document's revision bumped. +        # +        # idea is as follows: +        # A          B +        # a1         - +        #   `-------> +        # a1         a1 +        # v          v +        # a2         a1b1 +        #   `-------> +        # a1b1+a2    a1b1 +        #            v +        # a1b1+a2    a1b2 (a1b2 has same content as a2) +        #   `-------> +        # a3b2       a1b2 (autoresolved) +        #   `-------> +        # a3b2       a3b2 +        self.db1 = self.create_database('test1', 'source') +        self.db2 = self.create_database('test2', 'target') +        self.db1.create_doc_from_json(tests.simple_doc, doc_id='doc') +        self.sync(self.db1, self.db2) +        for db, content in [(self.db1, '{}'), (self.db2, '{"hi": 42}')]: +            doc = db.get_doc('doc') +            doc.set_json(content) +            db.put_doc(doc) +        self.sync(self.db1, self.db2) +        # db1 and db2 now both have a doc of {hi:42}, but db1 has a conflict +        doc = self.db1.get_doc('doc') +        rev1 = doc.rev +        self.assertTrue(doc.has_conflicts) +        # set db2 to have a doc of {} (same as db1 before the conflict) +        doc = self.db2.get_doc('doc') +        doc.set_json('{}') +        self.db2.put_doc(doc) +        rev2 = doc.rev +        # sync it across +        self.sync(self.db1, self.db2) +        # tadaa! +        doc = self.db1.get_doc('doc') +        self.assertFalse(doc.has_conflicts) +        vec1 = vectorclock.VectorClockRev(rev1) +        vec2 = vectorclock.VectorClockRev(rev2) +        vec3 = vectorclock.VectorClockRev(doc.rev) +        self.assertTrue(vec3.is_newer(vec1)) +        self.assertTrue(vec3.is_newer(vec2)) +        # because the conflict is on the source, sync it another time +        self.sync(self.db1, self.db2) +        # make sure db2 now has the exact same thing +        doc1 = self.db1.get_doc('doc') +        self.assertGetEncryptedDoc( +            self.db2, +            doc1.doc_id, doc1.rev, doc1.get_json(), False) + +    def test_sync_autoresolves_moar_backwards(self): +        # here we would test that when a database that has a conflicted +        # document is the target of a sync, and the source database has a +        # revision of the conflicted document that is newer than the target +        # database's, and that source's database's document's content is the +        # same as the target's document's conflict's, the target's document's +        # conflict gets autoresolved, and the document's revision bumped. +        # +        # Despite that, in Soledad we suppose that the server never syncs, so +        # it never has conflicted documents. Also, if it had, convergence +        # would not be possible by checking document's contents because they +        # would be encrypted in server. +        # +        # Therefore we suppress this test. +        pass + +    def test_sync_autoresolves_moar_backwards_three(self): +        # here we would test that when a database that has a conflicted +        # document is the target of a sync, and the source database has a +        # revision of the conflicted document that is newer than the target +        # database's, and that source's database's document's content is the +        # same as the target's document's conflict's, the target's document's +        # conflict gets autoresolved, and the document's revision bumped. +        # +        # We use the same reasoning from the last test to suppress this one. +        pass + +    def test_sync_propagates_resolution(self): +        """ +        Test if synchronization propagates resolution. + +        This test was adapted to decrypt remote content before assert. +        """ +        self.db1 = self.create_database('test1', 'both') +        self.db2 = self.create_database('test2', 'both') +        doc1 = self.db1.create_doc_from_json('{"a": 1}', doc_id='the-doc') +        db3 = self.create_database('test3', 'both') +        self.sync(self.db2, self.db1) +        self.assertEqual( +            self.db1._get_generation_info(), +            self.db2._get_replica_gen_and_trans_id(self.db1._replica_uid)) +        self.assertEqual( +            self.db2._get_generation_info(), +            self.db1._get_replica_gen_and_trans_id(self.db2._replica_uid)) +        self.sync(db3, self.db1) +        # update on 2 +        doc2 = self.make_document('the-doc', doc1.rev, '{"a": 2}') +        self.db2.put_doc(doc2) +        self.sync(self.db2, db3) +        self.assertEqual(db3.get_doc('the-doc').rev, doc2.rev) +        # update on 1 +        doc1.set_json('{"a": 3}') +        self.db1.put_doc(doc1) +        # conflicts +        self.sync(self.db2, self.db1) +        self.sync(db3, self.db1) +        self.assertTrue(self.db2.get_doc('the-doc').has_conflicts) +        self.assertTrue(db3.get_doc('the-doc').has_conflicts) +        # resolve +        conflicts = self.db2.get_doc_conflicts('the-doc') +        doc4 = self.make_document('the-doc', None, '{"a": 4}') +        revs = [doc.rev for doc in conflicts] +        self.db2.resolve_doc(doc4, revs) +        doc2 = self.db2.get_doc('the-doc') +        self.assertEqual(doc4.get_json(), doc2.get_json()) +        self.assertFalse(doc2.has_conflicts) +        self.sync(self.db2, db3) +        doc3 = db3.get_doc('the-doc') +        if ENC_SCHEME_KEY in doc3.content: +            doc3.set_json(decrypt_doc(self._soledad._crypto, doc3)) +        self.assertEqual(doc4.get_json(), doc3.get_json()) +        self.assertFalse(doc3.has_conflicts) + +    def test_sync_puts_changes(self): +        """ +        Test if sync puts changes in remote replica. + +        This test was adapted to decrypt remote content before assert. +        """ +        self.db1 = self.create_database('test1', 'source') +        self.db2 = self.create_database('test2', 'target') +        doc = self.db1.create_doc_from_json(tests.simple_doc) +        self.assertEqual(1, self.sync(self.db1, self.db2)) +        self.assertGetEncryptedDoc( +            self.db2, doc.doc_id, doc.rev, tests.simple_doc, False) +        self.assertEqual(1, self.db1._get_replica_gen_and_trans_id('test2')[0]) +        self.assertEqual(1, self.db2._get_replica_gen_and_trans_id('test1')[0]) +        self.assertLastExchangeLog( +            self.db2, +            {'receive': {'docs': [(doc.doc_id, doc.rev)], +                         'source_uid': 'test1', +                         'source_gen': 1, 'last_known_gen': 0}, +             'return': {'docs': [], 'last_gen': 1}}) + + +def _make_local_db_and_leap_target(test, path='test'): +    test.startServer() +    db = test.request_state._create_database(os.path.basename(path)) +    st = SoledadSyncTarget.connect(test.getURL(path), test._soledad._crypto) +    st.set_token_credentials('user-uuid', 'auth-token') +    return db, st + + +target_scenarios = [ +    ('leap', { +        'create_db_and_target': _make_local_db_and_leap_target, +        'make_app_with_state': tests.test_remote_sync_target.make_http_app}), +] + + +class SQLCipherSyncTargetTests( +        test_sync.DatabaseSyncTargetTests, BaseSoledadTest): + +    scenarios = (tests.multiply_scenarios(SQLCIPHER_SCENARIOS, +                                          target_scenarios)) + +    def setUp(self): +        test_sync.DatabaseSyncTargetTests.setUp(self) +        #BaseSoledadTest.setUp(self) + +    def tearDown(self): +        test_sync.DatabaseSyncTargetTests.tearDown(self) +        BaseSoledadTest.tearDown(self) + +    def test_sync_exchange(self): +        """ +        Modified to account for possibly receiving encrypted documents from +        sever-side. +        """ +        docs_by_gen = [ +            (self.make_document('doc-id', 'replica:1', tests.simple_doc), 10, +             'T-sid')] +        new_gen, trans_id = self.st.sync_exchange( +            docs_by_gen, 'replica', last_known_generation=0, +            last_known_trans_id=None, return_doc_cb=self.receive_doc) +        self.assertGetEncryptedDoc( +            self.db, 'doc-id', 'replica:1', tests.simple_doc, False) +        self.assertTransactionLog(['doc-id'], self.db) +        last_trans_id = self.getLastTransId(self.db) +        self.assertEqual(([], 1, last_trans_id), +                         (self.other_changes, new_gen, last_trans_id)) +        self.assertEqual(10, self.st.get_sync_info('replica')[3]) + +    def test_sync_exchange_push_many(self): +        """ +        Modified to account for possibly receiving encrypted documents from +        sever-side. +        """ +        docs_by_gen = [ +            (self.make_document( +                'doc-id', 'replica:1', tests.simple_doc), 10, 'T-1'), +            (self.make_document('doc-id2', 'replica:1', tests.nested_doc), 11, +             'T-2')] +        new_gen, trans_id = self.st.sync_exchange( +            docs_by_gen, 'replica', last_known_generation=0, +            last_known_trans_id=None, return_doc_cb=self.receive_doc) +        self.assertGetEncryptedDoc( +            self.db, 'doc-id', 'replica:1', tests.simple_doc, False) +        self.assertGetEncryptedDoc( +            self.db, 'doc-id2', 'replica:1', tests.nested_doc, False) +        self.assertTransactionLog(['doc-id', 'doc-id2'], self.db) +        last_trans_id = self.getLastTransId(self.db) +        self.assertEqual(([], 2, last_trans_id), +                         (self.other_changes, new_gen, trans_id)) +        self.assertEqual(11, self.st.get_sync_info('replica')[3]) + +    def test_sync_exchange_returns_many_new_docs(self): +        """ +        Modified to account for JSON serialization differences. +        """ +        doc = self.db.create_doc_from_json(tests.simple_doc) +        doc2 = self.db.create_doc_from_json(tests.nested_doc) +        self.assertTransactionLog([doc.doc_id, doc2.doc_id], self.db) +        new_gen, _ = self.st.sync_exchange( +            [], 'other-replica', last_known_generation=0, +            last_known_trans_id=None, return_doc_cb=self.receive_doc) +        self.assertTransactionLog([doc.doc_id, doc2.doc_id], self.db) +        self.assertEqual(2, new_gen) +        self.assertEqual( +            [(doc.doc_id, doc.rev, 1), +             (doc2.doc_id, doc2.rev, 2)], +            [c[:2] + c[3:4] for c in self.other_changes]) +        self.assertEqual( +            json.dumps(tests.simple_doc), +            json.dumps(self.other_changes[0][2])) +        self.assertEqual( +            json.loads(tests.nested_doc), +            json.loads(self.other_changes[1][2])) +        if self.whitebox: +            self.assertEqual( +                self.db._last_exchange_log['return'], +                {'last_gen': 2, 'docs': +                 [(doc.doc_id, doc.rev), (doc2.doc_id, doc2.rev)]}) + + +#----------------------------------------------------------------------------- +# Tests for actual encryption of the database +#----------------------------------------------------------------------------- + +class SQLCipherEncryptionTest(BaseLeapTest): +    """ +    Tests to guarantee SQLCipher is indeed encrypting data when storing. +    """ + +    def _delete_dbfiles(self): +        for dbfile in [self.DB_FILE]: +            if os.path.exists(dbfile): +                os.unlink(dbfile) + +    def setUp(self): +        self.DB_FILE = os.path.join(self.tempdir, 'test.db') +        self._delete_dbfiles() + +    def tearDown(self): +        self._delete_dbfiles() + +    def test_try_to_open_encrypted_db_with_sqlite_backend(self): +        """ +        SQLite backend should not succeed to open SQLCipher databases. +        """ +        db = SQLCipherDatabase(self.DB_FILE, PASSWORD) +        doc = db.create_doc_from_json(tests.simple_doc) +        db.close() +        try: +            # trying to open an encrypted database with the regular u1db +            # backend should raise a DatabaseError exception. +            SQLitePartialExpandDatabase(self.DB_FILE, +                                        document_factory=SoledadDocument) +            raise DatabaseIsNotEncrypted() +        except dbapi2.DatabaseError: +            # at this point we know that the regular U1DB sqlcipher backend +            # did not succeed on opening the database, so it was indeed +            # encrypted. +            db = SQLCipherDatabase(self.DB_FILE, PASSWORD) +            doc = db.get_doc(doc.doc_id) +            self.assertEqual(tests.simple_doc, doc.get_json(), +                             'decrypted content mismatch') + +    def test_try_to_open_raw_db_with_sqlcipher_backend(self): +        """ +        SQLCipher backend should not succeed to open unencrypted databases. +        """ +        db = SQLitePartialExpandDatabase(self.DB_FILE, +                                         document_factory=SoledadDocument) +        db.create_doc_from_json(tests.simple_doc) +        db.close() +        try: +            # trying to open the a non-encrypted database with sqlcipher +            # backend should raise a DatabaseIsNotEncrypted exception. +            SQLCipherDatabase(self.DB_FILE, PASSWORD) +            raise db1pi2.DatabaseError( +                "SQLCipher backend should not be able to open non-encrypted " +                "dbs.") +        except DatabaseIsNotEncrypted: +            pass + + +load_tests = tests.load_with_scenarios diff --git a/common/src/leap/soledad/common/tests/test_target.py b/common/src/leap/soledad/common/tests/test_target.py new file mode 100644 index 00000000..5a541745 --- /dev/null +++ b/common/src/leap/soledad/common/tests/test_target.py @@ -0,0 +1,784 @@ +# -*- coding: utf-8 -*- +# test_target.py +# Copyright (C) 2013 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +""" +Test Leap backend bits. +""" + +import u1db +import os +import ssl +import simplejson as json +import cStringIO + + +from u1db.sync import Synchronizer +from u1db.remote import ( +    http_client, +    http_database, +    http_target, +) + +from leap.soledad import client +from leap.soledad.client import ( +    target, +    auth, +    VerifiedHTTPSConnection, +) +from leap.soledad.common.document import SoledadDocument +from leap.soledad.server import SoledadApp +from leap.soledad.server.auth import SoledadTokenAuthMiddleware + + +from leap.soledad.common.tests import u1db_tests as tests +from leap.soledad.common.tests import BaseSoledadTest +from leap.soledad.common.tests.u1db_tests import test_backends +from leap.soledad.common.tests.u1db_tests import test_http_database +from leap.soledad.common.tests.u1db_tests import test_http_client +from leap.soledad.common.tests.u1db_tests import test_document +from leap.soledad.common.tests.u1db_tests import test_remote_sync_target +from leap.soledad.common.tests.u1db_tests import test_https +from leap.soledad.common.tests.u1db_tests import test_sync + + +#----------------------------------------------------------------------------- +# The following tests come from `u1db.tests.test_backends`. +#----------------------------------------------------------------------------- + +def make_leap_document_for_test(test, doc_id, rev, content, +                                has_conflicts=False): +    return SoledadDocument( +        doc_id, rev, content, has_conflicts=has_conflicts) + + +def make_soledad_app(state): +    return SoledadApp(state) + + +def make_token_soledad_app(state): +    app = SoledadApp(state) + +    def _verify_authentication_data(uuid, auth_data): +        if uuid == 'user-uuid' and auth_data == 'auth-token': +            return True +        return False + +    # we test for action authorization in leap.soledad.common.tests.test_server +    def _verify_authorization(uuid, environ): +        return True + +    application = SoledadTokenAuthMiddleware(app) +    application._verify_authentication_data = _verify_authentication_data +    application._verify_authorization = _verify_authorization +    return application + + +LEAP_SCENARIOS = [ +    ('http', { +        'make_database_for_test': test_backends.make_http_database_for_test, +        'copy_database_for_test': test_backends.copy_http_database_for_test, +        'make_document_for_test': make_leap_document_for_test, +        'make_app_with_state': make_soledad_app}), +] + + +def make_token_http_database_for_test(test, replica_uid): +    test.startServer() +    test.request_state._create_database(replica_uid) + +    class _HTTPDatabaseWithToken( +            http_database.HTTPDatabase, auth.TokenBasedAuth): + +        def set_token_credentials(self, uuid, token): +            auth.TokenBasedAuth.set_token_credentials(self, uuid, token) + +        def _sign_request(self, method, url_query, params): +            return auth.TokenBasedAuth._sign_request( +                self, method, url_query, params) + +    http_db = _HTTPDatabaseWithToken(test.getURL('test')) +    http_db.set_token_credentials('user-uuid', 'auth-token') +    return http_db + + +def copy_token_http_database_for_test(test, db): +    # DO NOT COPY OR REUSE THIS CODE OUTSIDE TESTS: COPYING U1DB DATABASES IS +    # THE WRONG THING TO DO, THE ONLY REASON WE DO SO HERE IS TO TEST THAT WE +    # CORRECTLY DETECT IT HAPPENING SO THAT WE CAN RAISE ERRORS RATHER THAN +    # CORRUPT USER DATA. USE SYNC INSTEAD, OR WE WILL SEND NINJA TO YOUR +    # HOUSE. +    http_db = test.request_state._copy_database(db) +    http_db.set_token_credentials(http_db, 'user-uuid', 'auth-token') +    return http_db + + +class SoledadTests(test_backends.AllDatabaseTests, BaseSoledadTest): + +    scenarios = LEAP_SCENARIOS + [ +        ('token_http', {'make_database_for_test': +                        make_token_http_database_for_test, +                        'copy_database_for_test': +                        copy_token_http_database_for_test, +                        'make_document_for_test': make_leap_document_for_test, +                        'make_app_with_state': make_token_soledad_app, +                        }) +    ] + + +#----------------------------------------------------------------------------- +# The following tests come from `u1db.tests.test_http_client`. +#----------------------------------------------------------------------------- + +class TestSoledadClientBase(test_http_client.TestHTTPClientBase): +    """ +    This class should be used to test Token auth. +    """ + +    def getClientWithToken(self, **kwds): +        self.startServer() + +        class _HTTPClientWithToken( +                http_client.HTTPClientBase, auth.TokenBasedAuth): + +            def set_token_credentials(self, uuid, token): +                auth.TokenBasedAuth.set_token_credentials(self, uuid, token) + +            def _sign_request(self, method, url_query, params): +                return auth.TokenBasedAuth._sign_request( +                    self, method, url_query, params) + +        return _HTTPClientWithToken(self.getURL('dbase'), **kwds) + +    def test_oauth(self): +        """ +        Suppress oauth test (we test for token auth here). +        """ +        pass + +    def test_oauth_ctr_creds(self): +        """ +        Suppress oauth test (we test for token auth here). +        """ +        pass + +    def test_oauth_Unauthorized(self): +        """ +        Suppress oauth test (we test for token auth here). +        """ +        pass + +    def app(self, environ, start_response): +        res = test_http_client.TestHTTPClientBase.app( +            self, environ, start_response) +        if res is not None: +            return res +        # mime solead application here. +        if '/token' in environ['PATH_INFO']: +            auth = environ.get(SoledadTokenAuthMiddleware.HTTP_AUTH_KEY) +            if not auth: +                start_response("401 Unauthorized", +                               [('Content-Type', 'application/json')]) +                return [json.dumps({"error": "unauthorized", +                                    "message": e.message})] +            scheme, encoded = auth.split(None, 1) +            if scheme.lower() != 'token': +                start_response("401 Unauthorized", +                               [('Content-Type', 'application/json')]) +                return [json.dumps({"error": "unauthorized", +                                    "message": e.message})] +            uuid, token = encoded.decode('base64').split(':', 1) +            if uuid != 'user-uuid' and token != 'auth-token': +                return unauth_err("Incorrect address or token.") +            start_response("200 OK", [('Content-Type', 'application/json')]) +            return [json.dumps([environ['PATH_INFO'], uuid, token])] + +    def test_token(self): +        """ +        Test if token is sent correctly. +        """ +        cli = self.getClientWithToken() +        cli.set_token_credentials('user-uuid', 'auth-token') +        res, headers = cli._request('GET', ['doc', 'token']) +        self.assertEqual( +            ['/dbase/doc/token', 'user-uuid', 'auth-token'], json.loads(res)) + +    def test_token_ctr_creds(self): +        cli = self.getClientWithToken(creds={'token': { +            'uuid': 'user-uuid', +            'token': 'auth-token', +        }}) +        res, headers = cli._request('GET', ['doc', 'token']) +        self.assertEqual( +            ['/dbase/doc/token', 'user-uuid', 'auth-token'], json.loads(res)) + + +#----------------------------------------------------------------------------- +# The following tests come from `u1db.tests.test_document`. +#----------------------------------------------------------------------------- + +class TestSoledadDocument(test_document.TestDocument, BaseSoledadTest): + +    scenarios = ([( +        'leap', {'make_document_for_test': make_leap_document_for_test})]) + + +class TestSoledadPyDocument(test_document.TestPyDocument, BaseSoledadTest): + +    scenarios = ([( +        'leap', {'make_document_for_test': make_leap_document_for_test})]) + + +#----------------------------------------------------------------------------- +# The following tests come from `u1db.tests.test_remote_sync_target`. +#----------------------------------------------------------------------------- + +class TestSoledadSyncTargetBasics( +        test_remote_sync_target.TestHTTPSyncTargetBasics): +    """ +    Some tests had to be copied to this class so we can instantiate our own +    target. +    """ + +    def test_parse_url(self): +        remote_target = target.SoledadSyncTarget('http://127.0.0.1:12345/') +        self.assertEqual('http', remote_target._url.scheme) +        self.assertEqual('127.0.0.1', remote_target._url.hostname) +        self.assertEqual(12345, remote_target._url.port) +        self.assertEqual('/', remote_target._url.path) + + +class TestSoledadParsingSyncStream( +        test_remote_sync_target.TestParsingSyncStream, +        BaseSoledadTest): +    """ +    Some tests had to be copied to this class so we can instantiate our own +    target. +    """ + +    def setUp(self): +        test_remote_sync_target.TestParsingSyncStream.setUp(self) +        BaseSoledadTest.setUp(self) + +    def tearDown(self): +        test_remote_sync_target.TestParsingSyncStream.tearDown(self) +        BaseSoledadTest.tearDown(self) + +    def test_extra_comma(self): +        """ +        Test adapted to use encrypted content. +        """ +        doc = SoledadDocument('i', rev='r') +        doc.content = {} +        enc_json = target.encrypt_doc(self._soledad._crypto, doc) +        tgt = target.SoledadSyncTarget( +            "http://foo/foo", crypto=self._soledad._crypto) + +        self.assertRaises(u1db.errors.BrokenSyncStream, +                          tgt._parse_sync_stream, "[\r\n{},\r\n]", None) +        self.assertRaises(u1db.errors.BrokenSyncStream, +                          tgt._parse_sync_stream, +                          '[\r\n{},\r\n{"id": "i", "rev": "r", ' +                          '"content": %s, "gen": 3, "trans_id": "T-sid"}' +                          ',\r\n]' % json.dumps(enc_json), +                          lambda doc, gen, trans_id: None) + +    def test_wrong_start(self): +        tgt = target.SoledadSyncTarget("http://foo/foo") + +        self.assertRaises(u1db.errors.BrokenSyncStream, +                          tgt._parse_sync_stream, "{}\r\n]", None) + +        self.assertRaises(u1db.errors.BrokenSyncStream, +                          tgt._parse_sync_stream, "\r\n{}\r\n]", None) + +        self.assertRaises(u1db.errors.BrokenSyncStream, +                          tgt._parse_sync_stream, "", None) + +    def test_wrong_end(self): +        tgt = target.SoledadSyncTarget("http://foo/foo") + +        self.assertRaises(u1db.errors.BrokenSyncStream, +                          tgt._parse_sync_stream, "[\r\n{}", None) + +        self.assertRaises(u1db.errors.BrokenSyncStream, +                          tgt._parse_sync_stream, "[\r\n", None) + +    def test_missing_comma(self): +        tgt = target.SoledadSyncTarget("http://foo/foo") + +        self.assertRaises(u1db.errors.BrokenSyncStream, +                          tgt._parse_sync_stream, +                          '[\r\n{}\r\n{"id": "i", "rev": "r", ' +                          '"content": "c", "gen": 3}\r\n]', None) + +    def test_no_entries(self): +        tgt = target.SoledadSyncTarget("http://foo/foo") + +        self.assertRaises(u1db.errors.BrokenSyncStream, +                          tgt._parse_sync_stream, "[\r\n]", None) + +    def test_error_in_stream(self): +        tgt = target.SoledadSyncTarget("http://foo/foo") + +        self.assertRaises(u1db.errors.Unavailable, +                          tgt._parse_sync_stream, +                          '[\r\n{"new_generation": 0},' +                          '\r\n{"error": "unavailable"}\r\n', None) + +        self.assertRaises(u1db.errors.Unavailable, +                          tgt._parse_sync_stream, +                          '[\r\n{"error": "unavailable"}\r\n', None) + +        self.assertRaises(u1db.errors.BrokenSyncStream, +                          tgt._parse_sync_stream, +                          '[\r\n{"error": "?"}\r\n', None) + + +# +# functions for TestRemoteSyncTargets +# + +def leap_sync_target(test, path): +    return target.SoledadSyncTarget( +        test.getURL(path), crypto=test._soledad._crypto) + + +def token_leap_sync_target(test, path): +    st = leap_sync_target(test, path) +    st.set_token_credentials('user-uuid', 'auth-token') +    return st + + +class TestSoledadSyncTarget( +        test_remote_sync_target.TestRemoteSyncTargets, BaseSoledadTest): + +    scenarios = [ +        ('token_soledad', +            {'make_app_with_state': make_token_soledad_app, +             'make_document_for_test': make_leap_document_for_test, +             'sync_target': token_leap_sync_target}), +    ] + +    def test_sync_exchange_send(self): +        """ +        Test for sync exchanging send of document. + +        This test was adapted to decrypt remote content before assert. +        """ +        self.startServer() +        db = self.request_state._create_database('test') +        remote_target = self.getSyncTarget('test') +        other_docs = [] + +        def receive_doc(doc): +            other_docs.append((doc.doc_id, doc.rev, doc.get_json())) + +        doc = self.make_document('doc-here', 'replica:1', '{"value": "here"}') +        new_gen, trans_id = remote_target.sync_exchange( +            [(doc, 10, 'T-sid')], 'replica', last_known_generation=0, +            last_known_trans_id=None, return_doc_cb=receive_doc) +        self.assertEqual(1, new_gen) +        self.assertGetEncryptedDoc( +            db, 'doc-here', 'replica:1', '{"value": "here"}', False) + +    def test_sync_exchange_send_failure_and_retry_scenario(self): +        """ +        Test for sync exchange failure and retry. + +        This test was adapted to decrypt remote content before assert. +        """ + +        self.startServer() + +        def blackhole_getstderr(inst): +            return cStringIO.StringIO() + +        self.patch(self.server.RequestHandlerClass, 'get_stderr', +                   blackhole_getstderr) +        db = self.request_state._create_database('test') +        _put_doc_if_newer = db._put_doc_if_newer +        trigger_ids = ['doc-here2'] + +        def bomb_put_doc_if_newer(doc, save_conflict, +                                  replica_uid=None, replica_gen=None, +                                  replica_trans_id=None): +            if doc.doc_id in trigger_ids: +                raise Exception +            return _put_doc_if_newer(doc, save_conflict=save_conflict, +                                     replica_uid=replica_uid, +                                     replica_gen=replica_gen, +                                     replica_trans_id=replica_trans_id) +        self.patch(db, '_put_doc_if_newer', bomb_put_doc_if_newer) +        remote_target = self.getSyncTarget('test') +        other_changes = [] + +        def receive_doc(doc, gen, trans_id): +            other_changes.append( +                (doc.doc_id, doc.rev, doc.get_json(), gen, trans_id)) + +        doc1 = self.make_document('doc-here', 'replica:1', '{"value": "here"}') +        doc2 = self.make_document('doc-here2', 'replica:1', +                                  '{"value": "here2"}') +        self.assertRaises( +            u1db.errors.HTTPError, +            remote_target.sync_exchange, +            [(doc1, 10, 'T-sid'), (doc2, 11, 'T-sud')], +            'replica', last_known_generation=0, last_known_trans_id=None, +            return_doc_cb=receive_doc) +        self.assertGetEncryptedDoc( +            db, 'doc-here', 'replica:1', '{"value": "here"}', +            False) +        self.assertEqual( +            (10, 'T-sid'), db._get_replica_gen_and_trans_id('replica')) +        self.assertEqual([], other_changes) +        # retry +        trigger_ids = [] +        new_gen, trans_id = remote_target.sync_exchange( +            [(doc2, 11, 'T-sud')], 'replica', last_known_generation=0, +            last_known_trans_id=None, return_doc_cb=receive_doc) +        self.assertGetEncryptedDoc( +            db, 'doc-here2', 'replica:1', '{"value": "here2"}', +            False) +        self.assertEqual( +            (11, 'T-sud'), db._get_replica_gen_and_trans_id('replica')) +        self.assertEqual(2, new_gen) +        # bounced back to us +        self.assertEqual( +            ('doc-here', 'replica:1', '{"value": "here"}', 1), +            other_changes[0][:-1]) + +    def test_sync_exchange_send_ensure_callback(self): +        """ +        Test for sync exchange failure and retry. + +        This test was adapted to decrypt remote content before assert. +        """ +        self.startServer() +        remote_target = self.getSyncTarget('test') +        other_docs = [] +        replica_uid_box = [] + +        def receive_doc(doc): +            other_docs.append((doc.doc_id, doc.rev, doc.get_json())) + +        def ensure_cb(replica_uid): +            replica_uid_box.append(replica_uid) + +        doc = self.make_document('doc-here', 'replica:1', '{"value": "here"}') +        new_gen, trans_id = remote_target.sync_exchange( +            [(doc, 10, 'T-sid')], 'replica', last_known_generation=0, +            last_known_trans_id=None, return_doc_cb=receive_doc, +            ensure_callback=ensure_cb) +        self.assertEqual(1, new_gen) +        db = self.request_state.open_database('test') +        self.assertEqual(1, len(replica_uid_box)) +        self.assertEqual(db._replica_uid, replica_uid_box[0]) +        self.assertGetEncryptedDoc( +            db, 'doc-here', 'replica:1', '{"value": "here"}', False) + + +#----------------------------------------------------------------------------- +# The following tests come from `u1db.tests.test_https`. +#----------------------------------------------------------------------------- + +def token_leap_https_sync_target(test, host, path): +    _, port = test.server.server_address +    st = target.SoledadSyncTarget( +        'https://%s:%d/%s' % (host, port, path), +        crypto=test._soledad._crypto) +    st.set_token_credentials('user-uuid', 'auth-token') +    return st + + +class TestSoledadSyncTargetHttpsSupport( +        test_https.TestHttpSyncTargetHttpsSupport, +        BaseSoledadTest): + +    scenarios = [ +        ('token_soledad_https', +            {'server_def': test_https.https_server_def, +             'make_app_with_state': make_token_soledad_app, +             'make_document_for_test': make_leap_document_for_test, +             'sync_target': token_leap_https_sync_target}), +    ] + +    def setUp(self): +        # the parent constructor undoes our SSL monkey patch to ensure tests +        # run smoothly with standard u1db. +        test_https.TestHttpSyncTargetHttpsSupport.setUp(self) +        # so here monkey patch again to test our functionality. +        http_client._VerifiedHTTPSConnection = VerifiedHTTPSConnection +        client.SOLEDAD_CERT = http_client.CA_CERTS + +    def test_working(self): +        """ +        Test that SSL connections work well. + +        This test was adapted to patch Soledad's HTTPS connection custom class +        with the intended CA certificates. +        """ +        self.startServer() +        db = self.request_state._create_database('test') +        self.patch(client, 'SOLEDAD_CERT', self.cacert_pem) +        remote_target = self.getSyncTarget('localhost', 'test') +        remote_target.record_sync_info('other-id', 2, 'T-id') +        self.assertEqual( +            (2, 'T-id'), db._get_replica_gen_and_trans_id('other-id')) + +    def test_host_mismatch(self): +        """ +        Test that SSL connections to a hostname different than the one in the +        certificate raise CertificateError. + +        This test was adapted to patch Soledad's HTTPS connection custom class +        with the intended CA certificates. +        """ +        self.startServer() +        self.request_state._create_database('test') +        self.patch(client, 'SOLEDAD_CERT', self.cacert_pem) +        remote_target = self.getSyncTarget('127.0.0.1', 'test') +        self.assertRaises( +            http_client.CertificateError, remote_target.record_sync_info, +            'other-id', 2, 'T-id') + + +#----------------------------------------------------------------------------- +# The following tests come from `u1db.tests.test_http_database`. +#----------------------------------------------------------------------------- + +class _HTTPDatabase(http_database.HTTPDatabase, auth.TokenBasedAuth): +    """ +    Wraps our token auth implementation. +    """ + +    def set_token_credentials(self, uuid, token): +        auth.TokenBasedAuth.set_token_credentials(self, uuid, token) + +    def _sign_request(self, method, url_query, params): +        return auth.TokenBasedAuth._sign_request( +            self, method, url_query, params) + + +class TestHTTPDatabaseWithCreds( +        test_http_database.TestHTTPDatabaseCtrWithCreds): + +    def test_get_sync_target_inherits_token_credentials(self): +        # this test was from TestDatabaseSimpleOperations but we put it here +        # for convenience. +        self.db = _HTTPDatabase('dbase') +        self.db.set_token_credentials('user-uuid', 'auth-token') +        st = self.db.get_sync_target() +        self.assertEqual(self.db._creds, st._creds) + +    def test_ctr_with_creds(self): +        db1 = _HTTPDatabase('http://dbs/db', creds={'token': { +            'uuid': 'user-uuid', +            'token': 'auth-token', +        }}) +        self.assertIn('token', db1._creds) + + +#----------------------------------------------------------------------------- +# The following tests come from `u1db.tests.test_sync`. +#----------------------------------------------------------------------------- + +def _make_local_db_and_leap_target(test, path='test'): +    test.startServer() +    db = test.request_state._create_database(os.path.basename(path)) +    st = target.SoledadSyncTarget.connect( +        test.getURL(path), crypto=test._soledad._crypto) +    return db, st + + +def _make_local_db_and_token_leap_target(test): +    db, st = _make_local_db_and_leap_target(test, 'test') +    st.set_token_credentials('user-uuid', 'auth-token') +    return db, st + + +target_scenarios = [ +    ('token_leap', {'create_db_and_target': +                    _make_local_db_and_token_leap_target, +                    'make_app_with_state': make_token_soledad_app}), +] + + +class SoledadDatabaseSyncTargetTests( +        test_sync.DatabaseSyncTargetTests, BaseSoledadTest): + +    scenarios = ( +        tests.multiply_scenarios( +            tests.DatabaseBaseTests.scenarios, +            target_scenarios)) + +    def test_sync_exchange(self): +        """ +        Test sync exchange. + +        This test was adapted to decrypt remote content before assert. +        """ +        sol = _make_local_db_and_leap_target(self) +        docs_by_gen = [ +            (self.make_document('doc-id', 'replica:1', tests.simple_doc), 10, +             'T-sid')] +        new_gen, trans_id = self.st.sync_exchange( +            docs_by_gen, 'replica', last_known_generation=0, +            last_known_trans_id=None, return_doc_cb=self.receive_doc) +        self.assertGetEncryptedDoc( +            self.db, 'doc-id', 'replica:1', tests.simple_doc, False) +        self.assertTransactionLog(['doc-id'], self.db) +        last_trans_id = self.getLastTransId(self.db) +        self.assertEqual(([], 1, last_trans_id), +                         (self.other_changes, new_gen, last_trans_id)) +        self.assertEqual(10, self.st.get_sync_info('replica')[3]) + +    def test_sync_exchange_push_many(self): +        """ +        Test sync exchange. + +        This test was adapted to decrypt remote content before assert. +        """ +        docs_by_gen = [ +            (self.make_document( +                'doc-id', 'replica:1', tests.simple_doc), 10, 'T-1'), +            (self.make_document( +                'doc-id2', 'replica:1', tests.nested_doc), 11, 'T-2')] +        new_gen, trans_id = self.st.sync_exchange( +            docs_by_gen, 'replica', last_known_generation=0, +            last_known_trans_id=None, return_doc_cb=self.receive_doc) +        self.assertGetEncryptedDoc( +            self.db, 'doc-id', 'replica:1', tests.simple_doc, False) +        self.assertGetEncryptedDoc( +            self.db, 'doc-id2', 'replica:1', tests.nested_doc, False) +        self.assertTransactionLog(['doc-id', 'doc-id2'], self.db) +        last_trans_id = self.getLastTransId(self.db) +        self.assertEqual(([], 2, last_trans_id), +                         (self.other_changes, new_gen, trans_id)) +        self.assertEqual(11, self.st.get_sync_info('replica')[3]) + +    def test_sync_exchange_returns_many_new_docs(self): +        """ +        Test sync exchange. + +        This test was adapted to avoid JSON serialization comparison as local +        and remote representations might differ. It looks directly at the +        doc's contents instead. +        """ +        doc = self.db.create_doc_from_json(tests.simple_doc) +        doc2 = self.db.create_doc_from_json(tests.nested_doc) +        self.assertTransactionLog([doc.doc_id, doc2.doc_id], self.db) +        new_gen, _ = self.st.sync_exchange( +            [], 'other-replica', last_known_generation=0, +            last_known_trans_id=None, return_doc_cb=self.receive_doc) +        self.assertTransactionLog([doc.doc_id, doc2.doc_id], self.db) +        self.assertEqual(2, new_gen) +        self.assertEqual( +            [(doc.doc_id, doc.rev, 1), +             (doc2.doc_id, doc2.rev, 2)], +            [c[:-3] + c[-2:-1] for c in self.other_changes]) +        self.assertEqual( +            json.loads(tests.simple_doc), +            json.loads(self.other_changes[0][2])) +        self.assertEqual( +            json.loads(tests.nested_doc), +            json.loads(self.other_changes[1][2])) +        if self.whitebox: +            self.assertEqual( +                self.db._last_exchange_log['return'], +                {'last_gen': 2, 'docs': +                 [(doc.doc_id, doc.rev), (doc2.doc_id, doc2.rev)]}) + + +class TestSoledadDbSync(test_sync.TestDbSync, BaseSoledadTest): +    """Test db.sync remote sync shortcut""" + +    scenarios = [ +        ('py-http', { +            'make_app_with_state': make_soledad_app, +            'make_database_for_test': tests.make_memory_database_for_test, +        }), +        ('py-token-http', { +            'make_app_with_state': make_token_soledad_app, +            'make_database_for_test': tests.make_memory_database_for_test, +            'token': True +        }), +    ] + +    oauth = False +    token = False + +    def do_sync(self, target_name): +        """ +        Perform sync using SoledadSyncTarget and Token auth. +        """ +        if self.token: +            extra = dict(creds={'token': { +                'uuid': 'user-uuid', +                'token': 'auth-token', +            }}) +            target_url = self.getURL(target_name) +            return Synchronizer( +                self.db, +                target.SoledadSyncTarget( +                    target_url, +                    crypto=self._soledad._crypto, +                    **extra)).sync(autocreate=True) +        else: +            return test_sync.TestDbSync.do_sync(self, target_name) + +    def test_db_sync(self): +        """ +        Test sync. + +        Adapted to check for encrypted content. +        """ +        doc1 = self.db.create_doc_from_json(tests.simple_doc) +        doc2 = self.db2.create_doc_from_json(tests.nested_doc) +        local_gen_before_sync = self.do_sync('test2.db') +        gen, _, changes = self.db.whats_changed(local_gen_before_sync) +        self.assertEqual(1, len(changes)) +        self.assertEqual(doc2.doc_id, changes[0][0]) +        self.assertEqual(1, gen - local_gen_before_sync) +        self.assertGetEncryptedDoc( +            self.db2, doc1.doc_id, doc1.rev, tests.simple_doc, False) +        self.assertGetEncryptedDoc( +            self.db, doc2.doc_id, doc2.rev, tests.nested_doc, False) + +    def test_db_sync_autocreate(self): +        """ +        Test sync. + +        Adapted to check for encrypted content. +        """ +        doc1 = self.db.create_doc_from_json(tests.simple_doc) +        local_gen_before_sync = self.do_sync('test3.db') +        gen, _, changes = self.db.whats_changed(local_gen_before_sync) +        self.assertEqual(0, gen - local_gen_before_sync) +        db3 = self.request_state.open_database('test3.db') +        gen, _, changes = db3.whats_changed() +        self.assertEqual(1, len(changes)) +        self.assertEqual(doc1.doc_id, changes[0][0]) +        self.assertGetEncryptedDoc( +            db3, doc1.doc_id, doc1.rev, tests.simple_doc, False) +        t_gen, _ = self.db._get_replica_gen_and_trans_id('test3.db') +        s_gen, _ = db3._get_replica_gen_and_trans_id('test1') +        self.assertEqual(1, t_gen) +        self.assertEqual(1, s_gen) + + +load_tests = tests.load_with_scenarios diff --git a/common/src/leap/soledad/common/tests/u1db_tests/README b/common/src/leap/soledad/common/tests/u1db_tests/README new file mode 100644 index 00000000..605f01fa --- /dev/null +++ b/common/src/leap/soledad/common/tests/u1db_tests/README @@ -0,0 +1,34 @@ +General info +------------ + +Test files in this directory are derived from u1db-0.1.4 tests. The main +difference is that: + +  (1) they include the test infrastructure packed with soledad; and +  (2) they do not include c_backend_wrapper testing. + +Dependencies +------------ + +u1db tests depend on the following python packages: + +  nose2 +  unittest2 +  mercurial +  hgtools +  testtools +  discover +  oauth +  testscenarios +  dirspec +  paste +  routes +  simplejson +  cython + +Running tests +------------- + +Use nose2 to run tests: + +  nose2 leap.soledad.tests.u1db_tests diff --git a/common/src/leap/soledad/common/tests/u1db_tests/__init__.py b/common/src/leap/soledad/common/tests/u1db_tests/__init__.py new file mode 100644 index 00000000..3bc12487 --- /dev/null +++ b/common/src/leap/soledad/common/tests/u1db_tests/__init__.py @@ -0,0 +1,424 @@ +# Copyright 2011-2012 Canonical Ltd. +# +# This file is part of u1db. +# +# u1db is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License version 3 +# as published by the Free Software Foundation. +# +# u1db is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with u1db.  If not, see <http://www.gnu.org/licenses/>. + +"""Test infrastructure for U1DB""" + +import copy +import shutil +import socket +import tempfile +import threading + +try: +    import simplejson as json +except ImportError: +    import json  # noqa + +from wsgiref import simple_server + +from oauth import oauth +from pysqlcipher import dbapi2 +from StringIO import StringIO + +import testscenarios +import testtools + +from u1db import ( +    errors, +    Document, +) +from u1db.backends import ( +    inmemory, +    sqlite_backend, +) +from u1db.remote import ( +    server_state, +) + + +class TestCase(testtools.TestCase): + +    def createTempDir(self, prefix='u1db-tmp-'): +        """Create a temporary directory to do some work in. + +        This directory will be scheduled for cleanup when the test ends. +        """ +        tempdir = tempfile.mkdtemp(prefix=prefix) +        self.addCleanup(shutil.rmtree, tempdir) +        return tempdir + +    def make_document(self, doc_id, doc_rev, content, has_conflicts=False): +        return self.make_document_for_test( +            self, doc_id, doc_rev, content, has_conflicts) + +    def make_document_for_test(self, test, doc_id, doc_rev, content, +                               has_conflicts): +        return make_document_for_test( +            test, doc_id, doc_rev, content, has_conflicts) + +    def assertGetDoc(self, db, doc_id, doc_rev, content, has_conflicts): +        """Assert that the document in the database looks correct.""" +        exp_doc = self.make_document(doc_id, doc_rev, content, +                                     has_conflicts=has_conflicts) +        self.assertEqual(exp_doc, db.get_doc(doc_id)) + +    def assertGetDocIncludeDeleted(self, db, doc_id, doc_rev, content, +                                   has_conflicts): +        """Assert that the document in the database looks correct.""" +        exp_doc = self.make_document(doc_id, doc_rev, content, +                                     has_conflicts=has_conflicts) +        self.assertEqual(exp_doc, db.get_doc(doc_id, include_deleted=True)) + +    def assertGetDocConflicts(self, db, doc_id, conflicts): +        """Assert what conflicts are stored for a given doc_id. + +        :param conflicts: A list of (doc_rev, content) pairs. +            The first item must match the first item returned from the +            database, however the rest can be returned in any order. +        """ +        if conflicts: +            conflicts = [(rev, +                          (json.loads(cont) if isinstance(cont, basestring) +                           else cont)) for (rev, cont) in conflicts] +            conflicts = conflicts[:1] + sorted(conflicts[1:]) +        actual = db.get_doc_conflicts(doc_id) +        if actual: +            actual = [ +                (doc.rev, (json.loads(doc.get_json()) +                           if doc.get_json() is not None else None)) +                for doc in actual] +            actual = actual[:1] + sorted(actual[1:]) +        self.assertEqual(conflicts, actual) + + +def multiply_scenarios(a_scenarios, b_scenarios): +    """Create the cross-product of scenarios.""" + +    all_scenarios = [] +    for a_name, a_attrs in a_scenarios: +        for b_name, b_attrs in b_scenarios: +            name = '%s,%s' % (a_name, b_name) +            attrs = dict(a_attrs) +            attrs.update(b_attrs) +            all_scenarios.append((name, attrs)) +    return all_scenarios + + +simple_doc = '{"key": "value"}' +nested_doc = '{"key": "value", "sub": {"doc": "underneath"}}' + + +def make_memory_database_for_test(test, replica_uid): +    return inmemory.InMemoryDatabase(replica_uid) + + +def copy_memory_database_for_test(test, db): +    # DO NOT COPY OR REUSE THIS CODE OUTSIDE TESTS: COPYING U1DB DATABASES IS +    # THE WRONG THING TO DO, THE ONLY REASON WE DO SO HERE IS TO TEST THAT WE +    # CORRECTLY DETECT IT HAPPENING SO THAT WE CAN RAISE ERRORS RATHER THAN +    # CORRUPT USER DATA. USE SYNC INSTEAD, OR WE WILL SEND NINJA TO YOUR +    # HOUSE. +    new_db = inmemory.InMemoryDatabase(db._replica_uid) +    new_db._transaction_log = db._transaction_log[:] +    new_db._docs = copy.deepcopy(db._docs) +    new_db._conflicts = copy.deepcopy(db._conflicts) +    new_db._indexes = copy.deepcopy(db._indexes) +    new_db._factory = db._factory +    return new_db + + +def make_sqlite_partial_expanded_for_test(test, replica_uid): +    db = sqlite_backend.SQLitePartialExpandDatabase(':memory:') +    db._set_replica_uid(replica_uid) +    return db + + +def copy_sqlite_partial_expanded_for_test(test, db): +    # DO NOT COPY OR REUSE THIS CODE OUTSIDE TESTS: COPYING U1DB DATABASES IS +    # THE WRONG THING TO DO, THE ONLY REASON WE DO SO HERE IS TO TEST THAT WE +    # CORRECTLY DETECT IT HAPPENING SO THAT WE CAN RAISE ERRORS RATHER THAN +    # CORRUPT USER DATA. USE SYNC INSTEAD, OR WE WILL SEND NINJA TO YOUR +    # HOUSE. +    new_db = sqlite_backend.SQLitePartialExpandDatabase(':memory:') +    tmpfile = StringIO() +    for line in db._db_handle.iterdump(): +        if not 'sqlite_sequence' in line:  # work around bug in iterdump +            tmpfile.write('%s\n' % line) +    tmpfile.seek(0) +    new_db._db_handle = dbapi2.connect(':memory:') +    new_db._db_handle.cursor().executescript(tmpfile.read()) +    new_db._db_handle.commit() +    new_db._set_replica_uid(db._replica_uid) +    new_db._factory = db._factory +    return new_db + + +def make_document_for_test(test, doc_id, rev, content, has_conflicts=False): +    return Document(doc_id, rev, content, has_conflicts=has_conflicts) + + +LOCAL_DATABASES_SCENARIOS = [ +    ('mem', {'make_database_for_test': make_memory_database_for_test, +             'copy_database_for_test': copy_memory_database_for_test, +             'make_document_for_test': make_document_for_test}), +    ('sql', {'make_database_for_test': +             make_sqlite_partial_expanded_for_test, +             'copy_database_for_test': +             copy_sqlite_partial_expanded_for_test, +             'make_document_for_test': make_document_for_test}), +] + + +class DatabaseBaseTests(TestCase): + +    accept_fixed_trans_id = False  # set to True assertTransactionLog +                                   # is happy with all trans ids = '' + +    scenarios = LOCAL_DATABASES_SCENARIOS + +    def make_database_for_test(self, replica_uid): +        return make_memory_database_for_test(self, replica_uid) + +    def create_database(self, *args): +        return self.make_database_for_test(self, *args) + +    def copy_database(self, db): +        # DO NOT COPY OR REUSE THIS CODE OUTSIDE TESTS: COPYING U1DB DATABASES +        # IS THE WRONG THING TO DO, THE ONLY REASON WE DO SO HERE IS TO TEST +        # THAT WE CORRECTLY DETECT IT HAPPENING SO THAT WE CAN RAISE ERRORS +        # RATHER THAN CORRUPT USER DATA. USE SYNC INSTEAD, OR WE WILL SEND +        # NINJA TO YOUR HOUSE. +        return self.copy_database_for_test(self, db) + +    def setUp(self): +        super(DatabaseBaseTests, self).setUp() +        self.db = self.create_database('test') + +    def tearDown(self): +        # TODO: Add close_database parameterization +        # self.close_database(self.db) +        super(DatabaseBaseTests, self).tearDown() + +    def assertTransactionLog(self, doc_ids, db): +        """Assert that the given docs are in the transaction log.""" +        log = db._get_transaction_log() +        just_ids = [] +        seen_transactions = set() +        for doc_id, transaction_id in log: +            just_ids.append(doc_id) +            self.assertIsNot(None, transaction_id, +                             "Transaction id should not be None") +            if transaction_id == '' and self.accept_fixed_trans_id: +                continue +            self.assertNotEqual('', transaction_id, +                                "Transaction id should be a unique string") +            self.assertTrue(transaction_id.startswith('T-')) +            self.assertNotIn(transaction_id, seen_transactions) +            seen_transactions.add(transaction_id) +        self.assertEqual(doc_ids, just_ids) + +    def getLastTransId(self, db): +        """Return the transaction id for the last database update.""" +        return self.db._get_transaction_log()[-1][-1] + + +class ServerStateForTests(server_state.ServerState): +    """Used in the test suite, so we don't have to touch disk, etc.""" + +    def __init__(self): +        super(ServerStateForTests, self).__init__() +        self._dbs = {} + +    def open_database(self, path): +        try: +            return self._dbs[path] +        except KeyError: +            raise errors.DatabaseDoesNotExist + +    def check_database(self, path): +        # cares only about the possible exception +        self.open_database(path) + +    def ensure_database(self, path): +        try: +            db = self.open_database(path) +        except errors.DatabaseDoesNotExist: +            db = self._create_database(path) +        return db, db._replica_uid + +    def _copy_database(self, db): +        # DO NOT COPY OR REUSE THIS CODE OUTSIDE TESTS: COPYING U1DB DATABASES +        # IS THE WRONG THING TO DO, THE ONLY REASON WE DO SO HERE IS TO TEST +        # THAT WE CORRECTLY DETECT IT HAPPENING SO THAT WE CAN RAISE ERRORS +        # RATHER THAN CORRUPT USER DATA. USE SYNC INSTEAD, OR WE WILL SEND +        # NINJA TO YOUR HOUSE. +        new_db = copy_memory_database_for_test(None, db) +        path = db._replica_uid +        while path in self._dbs: +            path += 'copy' +        self._dbs[path] = new_db +        return new_db + +    def _create_database(self, path): +        db = inmemory.InMemoryDatabase(path) +        self._dbs[path] = db +        return db + +    def delete_database(self, path): +        del self._dbs[path] + + +class ResponderForTests(object): +    """Responder for tests.""" +    _started = False +    sent_response = False +    status = None + +    def start_response(self, status='success', **kwargs): +        self._started = True +        self.status = status +        self.kwargs = kwargs + +    def send_response(self, status='success', **kwargs): +        self.start_response(status, **kwargs) +        self.finish_response() + +    def finish_response(self): +        self.sent_response = True + + +class TestCaseWithServer(TestCase): + +    @staticmethod +    def server_def(): +        # hook point +        # should return (ServerClass, "shutdown method name", "url_scheme") +        class _RequestHandler(simple_server.WSGIRequestHandler): +            def log_request(*args): +                pass  # suppress + +        def make_server(host_port, application): +            assert application, "forgot to override make_app(_with_state)?" +            srv = simple_server.WSGIServer(host_port, _RequestHandler) +            # patch the value in if it's None +            if getattr(application, 'base_url', 1) is None: +                application.base_url = "http://%s:%s" % srv.server_address +            srv.set_app(application) +            return srv + +        return make_server, "shutdown", "http" + +    @staticmethod +    def make_app_with_state(state): +        # hook point +        return None + +    def make_app(self): +        # potential hook point +        self.request_state = ServerStateForTests() +        return self.make_app_with_state(self.request_state) + +    def setUp(self): +        super(TestCaseWithServer, self).setUp() +        self.server = self.server_thread = None + +    @property +    def url_scheme(self): +        return self.server_def()[-1] + +    def startServer(self): +        server_def = self.server_def() +        server_class, shutdown_meth, _ = server_def +        application = self.make_app() +        self.server = server_class(('127.0.0.1', 0), application) +        self.server_thread = threading.Thread(target=self.server.serve_forever, +                                              kwargs=dict(poll_interval=0.01)) +        self.server_thread.start() +        self.addCleanup(self.server_thread.join) +        self.addCleanup(getattr(self.server, shutdown_meth)) + +    def getURL(self, path=None): +        host, port = self.server.server_address +        if path is None: +            path = '' +        return '%s://%s:%s/%s' % (self.url_scheme, host, port, path) + + +def socket_pair(): +    """Return a pair of TCP sockets connected to each other. + +    Unlike socket.socketpair, this should work on Windows. +    """ +    sock_pair = getattr(socket, 'socket_pair', None) +    if sock_pair: +        return sock_pair(socket.AF_INET, socket.SOCK_STREAM) +    listen_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +    listen_sock.bind(('127.0.0.1', 0)) +    listen_sock.listen(1) +    client_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +    client_sock.connect(listen_sock.getsockname()) +    server_sock, addr = listen_sock.accept() +    listen_sock.close() +    return server_sock, client_sock + + +# OAuth related testing + +consumer1 = oauth.OAuthConsumer('K1', 'S1') +token1 = oauth.OAuthToken('kkkk1', 'XYZ') +consumer2 = oauth.OAuthConsumer('K2', 'S2') +token2 = oauth.OAuthToken('kkkk2', 'ZYX') +token3 = oauth.OAuthToken('kkkk3', 'ZYX') + + +class TestingOAuthDataStore(oauth.OAuthDataStore): +    """In memory predefined OAuthDataStore for testing.""" + +    consumers = { +        consumer1.key: consumer1, +        consumer2.key: consumer2, +    } + +    tokens = { +        token1.key: token1, +        token2.key: token2 +    } + +    def lookup_consumer(self, key): +        return self.consumers.get(key) + +    def lookup_token(self, token_type, token_token): +        return self.tokens.get(token_token) + +    def lookup_nonce(self, oauth_consumer, oauth_token, nonce): +        return None + +testingOAuthStore = TestingOAuthDataStore() + +sign_meth_HMAC_SHA1 = oauth.OAuthSignatureMethod_HMAC_SHA1() +sign_meth_PLAINTEXT = oauth.OAuthSignatureMethod_PLAINTEXT() + + +def load_with_scenarios(loader, standard_tests, pattern): +    """Load the tests in a given module. + +    This just applies testscenarios.generate_scenarios to all the tests that +    are present. We do it at load time rather than at run time, because it +    plays nicer with various tools. +    """ +    suite = loader.suiteClass() +    suite.addTests(testscenarios.generate_scenarios(standard_tests)) +    return suite diff --git a/common/src/leap/soledad/common/tests/u1db_tests/test_backends.py b/common/src/leap/soledad/common/tests/u1db_tests/test_backends.py new file mode 100644 index 00000000..d2a91d11 --- /dev/null +++ b/common/src/leap/soledad/common/tests/u1db_tests/test_backends.py @@ -0,0 +1,1907 @@ +# Copyright 2011 Canonical Ltd. +# +# This file is part of u1db. +# +# u1db is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License version 3 +# as published by the Free Software Foundation. +# +# u1db is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with u1db.  If not, see <http://www.gnu.org/licenses/>. + +"""The backend class for U1DB. This deals with hiding storage details.""" + +try: +    import simplejson as json +except ImportError: +    import json  # noqa +from u1db import ( +    DocumentBase, +    errors, +    vectorclock, +) + +from leap.soledad.common.tests import u1db_tests as tests + +simple_doc = tests.simple_doc +nested_doc = tests.nested_doc + +from leap.soledad.common.tests.u1db_tests.test_remote_sync_target import ( +    make_http_app, +    make_oauth_http_app, +) + +from u1db.remote import ( +    http_database, +) + + +def make_http_database_for_test(test, replica_uid, path='test'): +    test.startServer() +    test.request_state._create_database(replica_uid) +    return http_database.HTTPDatabase(test.getURL(path)) + + +def copy_http_database_for_test(test, db): +    # DO NOT COPY OR REUSE THIS CODE OUTSIDE TESTS: COPYING U1DB DATABASES IS +    # THE WRONG THING TO DO, THE ONLY REASON WE DO SO HERE IS TO TEST THAT WE +    # CORRECTLY DETECT IT HAPPENING SO THAT WE CAN RAISE ERRORS RATHER THAN +    # CORRUPT USER DATA. USE SYNC INSTEAD, OR WE WILL SEND NINJA TO YOUR +    # HOUSE. +    return test.request_state._copy_database(db) + + +def make_oauth_http_database_for_test(test, replica_uid): +    http_db = make_http_database_for_test(test, replica_uid, '~/test') +    http_db.set_oauth_credentials(tests.consumer1.key, tests.consumer1.secret, +                                  tests.token1.key, tests.token1.secret) +    return http_db + + +def copy_oauth_http_database_for_test(test, db): +    # DO NOT COPY OR REUSE THIS CODE OUTSIDE TESTS: COPYING U1DB DATABASES IS +    # THE WRONG THING TO DO, THE ONLY REASON WE DO SO HERE IS TO TEST THAT WE +    # CORRECTLY DETECT IT HAPPENING SO THAT WE CAN RAISE ERRORS RATHER THAN +    # CORRUPT USER DATA. USE SYNC INSTEAD, OR WE WILL SEND NINJA TO YOUR +    # HOUSE. +    http_db = test.request_state._copy_database(db) +    http_db.set_oauth_credentials(tests.consumer1.key, tests.consumer1.secret, +                                  tests.token1.key, tests.token1.secret) +    return http_db + + +class TestAlternativeDocument(DocumentBase): +    """A (not very) alternative implementation of Document.""" + + +class AllDatabaseTests(tests.DatabaseBaseTests, tests.TestCaseWithServer): + +    scenarios = tests.LOCAL_DATABASES_SCENARIOS + [ +        ('http', {'make_database_for_test': make_http_database_for_test, +                  'copy_database_for_test': copy_http_database_for_test, +                  'make_document_for_test': tests.make_document_for_test, +                  'make_app_with_state': make_http_app}), +        ('oauth_http', {'make_database_for_test': +                        make_oauth_http_database_for_test, +                        'copy_database_for_test': +                        copy_oauth_http_database_for_test, +                        'make_document_for_test': tests.make_document_for_test, +                        'make_app_with_state': make_oauth_http_app}) +    ] + +    def test_close(self): +        self.db.close() + +    def test_create_doc_allocating_doc_id(self): +        doc = self.db.create_doc_from_json(simple_doc) +        self.assertNotEqual(None, doc.doc_id) +        self.assertNotEqual(None, doc.rev) +        self.assertGetDoc(self.db, doc.doc_id, doc.rev, simple_doc, False) + +    def test_create_doc_different_ids_same_db(self): +        doc1 = self.db.create_doc_from_json(simple_doc) +        doc2 = self.db.create_doc_from_json(nested_doc) +        self.assertNotEqual(doc1.doc_id, doc2.doc_id) + +    def test_create_doc_with_id(self): +        doc = self.db.create_doc_from_json(simple_doc, doc_id='my-id') +        self.assertEqual('my-id', doc.doc_id) +        self.assertNotEqual(None, doc.rev) +        self.assertGetDoc(self.db, doc.doc_id, doc.rev, simple_doc, False) + +    def test_create_doc_existing_id(self): +        doc = self.db.create_doc_from_json(simple_doc) +        new_content = '{"something": "else"}' +        self.assertRaises( +            errors.RevisionConflict, self.db.create_doc_from_json, +            new_content, doc.doc_id) +        self.assertGetDoc(self.db, doc.doc_id, doc.rev, simple_doc, False) + +    def test_put_doc_creating_initial(self): +        doc = self.make_document('my_doc_id', None, simple_doc) +        new_rev = self.db.put_doc(doc) +        self.assertIsNot(None, new_rev) +        self.assertGetDoc(self.db, 'my_doc_id', new_rev, simple_doc, False) + +    def test_put_doc_space_in_id(self): +        doc = self.make_document('my doc id', None, simple_doc) +        self.assertRaises(errors.InvalidDocId, self.db.put_doc, doc) + +    def test_put_doc_update(self): +        doc = self.db.create_doc_from_json(simple_doc, doc_id='my_doc_id') +        orig_rev = doc.rev +        doc.set_json('{"updated": "stuff"}') +        new_rev = self.db.put_doc(doc) +        self.assertNotEqual(new_rev, orig_rev) +        self.assertGetDoc(self.db, 'my_doc_id', new_rev, +                          '{"updated": "stuff"}', False) +        self.assertEqual(doc.rev, new_rev) + +    def test_put_non_ascii_key(self): +        content = json.dumps({u'key\xe5': u'val'}) +        doc = self.db.create_doc_from_json(content, doc_id='my_doc') +        self.assertGetDoc(self.db, 'my_doc', doc.rev, content, False) + +    def test_put_non_ascii_value(self): +        content = json.dumps({'key': u'\xe5'}) +        doc = self.db.create_doc_from_json(content, doc_id='my_doc') +        self.assertGetDoc(self.db, 'my_doc', doc.rev, content, False) + +    def test_put_doc_refuses_no_id(self): +        doc = self.make_document(None, None, simple_doc) +        self.assertRaises(errors.InvalidDocId, self.db.put_doc, doc) +        doc = self.make_document("", None, simple_doc) +        self.assertRaises(errors.InvalidDocId, self.db.put_doc, doc) + +    def test_put_doc_refuses_slashes(self): +        doc = self.make_document('a/b', None, simple_doc) +        self.assertRaises(errors.InvalidDocId, self.db.put_doc, doc) +        doc = self.make_document(r'\b', None, simple_doc) +        self.assertRaises(errors.InvalidDocId, self.db.put_doc, doc) + +    def test_put_doc_url_quoting_is_fine(self): +        doc_id = "%2F%2Ffoo%2Fbar" +        doc = self.make_document(doc_id, None, simple_doc) +        new_rev = self.db.put_doc(doc) +        self.assertGetDoc(self.db, doc_id, new_rev, simple_doc, False) + +    def test_put_doc_refuses_non_existing_old_rev(self): +        doc = self.make_document('doc-id', 'test:4', simple_doc) +        self.assertRaises(errors.RevisionConflict, self.db.put_doc, doc) + +    def test_put_doc_refuses_non_ascii_doc_id(self): +        doc = self.make_document('d\xc3\xa5c-id', None, simple_doc) +        self.assertRaises(errors.InvalidDocId, self.db.put_doc, doc) + +    def test_put_fails_with_bad_old_rev(self): +        doc = self.db.create_doc_from_json(simple_doc, doc_id='my_doc_id') +        old_rev = doc.rev +        bad_doc = self.make_document(doc.doc_id, 'other:1', +                                     '{"something": "else"}') +        self.assertRaises(errors.RevisionConflict, self.db.put_doc, bad_doc) +        self.assertGetDoc(self.db, 'my_doc_id', old_rev, simple_doc, False) + +    def test_create_succeeds_after_delete(self): +        doc = self.db.create_doc_from_json(simple_doc, doc_id='my_doc_id') +        self.db.delete_doc(doc) +        deleted_doc = self.db.get_doc('my_doc_id', include_deleted=True) +        deleted_vc = vectorclock.VectorClockRev(deleted_doc.rev) +        new_doc = self.db.create_doc_from_json(simple_doc, doc_id='my_doc_id') +        self.assertGetDoc(self.db, 'my_doc_id', new_doc.rev, simple_doc, False) +        new_vc = vectorclock.VectorClockRev(new_doc.rev) +        self.assertTrue( +            new_vc.is_newer(deleted_vc), +            "%s does not supersede %s" % (new_doc.rev, deleted_doc.rev)) + +    def test_put_succeeds_after_delete(self): +        doc = self.db.create_doc_from_json(simple_doc, doc_id='my_doc_id') +        self.db.delete_doc(doc) +        deleted_doc = self.db.get_doc('my_doc_id', include_deleted=True) +        deleted_vc = vectorclock.VectorClockRev(deleted_doc.rev) +        doc2 = self.make_document('my_doc_id', None, simple_doc) +        self.db.put_doc(doc2) +        self.assertGetDoc(self.db, 'my_doc_id', doc2.rev, simple_doc, False) +        new_vc = vectorclock.VectorClockRev(doc2.rev) +        self.assertTrue( +            new_vc.is_newer(deleted_vc), +            "%s does not supersede %s" % (doc2.rev, deleted_doc.rev)) + +    def test_get_doc_after_put(self): +        doc = self.db.create_doc_from_json(simple_doc, doc_id='my_doc_id') +        self.assertGetDoc(self.db, 'my_doc_id', doc.rev, simple_doc, False) + +    def test_get_doc_nonexisting(self): +        self.assertIs(None, self.db.get_doc('non-existing')) + +    def test_get_doc_deleted(self): +        doc = self.db.create_doc_from_json(simple_doc, doc_id='my_doc_id') +        self.db.delete_doc(doc) +        self.assertIs(None, self.db.get_doc('my_doc_id')) + +    def test_get_doc_include_deleted(self): +        doc = self.db.create_doc_from_json(simple_doc, doc_id='my_doc_id') +        self.db.delete_doc(doc) +        self.assertGetDocIncludeDeleted( +            self.db, doc.doc_id, doc.rev, None, False) + +    def test_get_docs(self): +        doc1 = self.db.create_doc_from_json(simple_doc) +        doc2 = self.db.create_doc_from_json(nested_doc) +        self.assertEqual([doc1, doc2], +                         list(self.db.get_docs([doc1.doc_id, doc2.doc_id]))) + +    def test_get_docs_deleted(self): +        doc1 = self.db.create_doc_from_json(simple_doc) +        doc2 = self.db.create_doc_from_json(nested_doc) +        self.db.delete_doc(doc1) +        self.assertEqual([doc2], +                         list(self.db.get_docs([doc1.doc_id, doc2.doc_id]))) + +    def test_get_docs_include_deleted(self): +        doc1 = self.db.create_doc_from_json(simple_doc) +        doc2 = self.db.create_doc_from_json(nested_doc) +        self.db.delete_doc(doc1) +        self.assertEqual( +            [doc1, doc2], +            list(self.db.get_docs([doc1.doc_id, doc2.doc_id], +                                  include_deleted=True))) + +    def test_get_docs_request_ordered(self): +        doc1 = self.db.create_doc_from_json(simple_doc) +        doc2 = self.db.create_doc_from_json(nested_doc) +        self.assertEqual([doc1, doc2], +                         list(self.db.get_docs([doc1.doc_id, doc2.doc_id]))) +        self.assertEqual([doc2, doc1], +                         list(self.db.get_docs([doc2.doc_id, doc1.doc_id]))) + +    def test_get_docs_empty_list(self): +        self.assertEqual([], list(self.db.get_docs([]))) + +    def test_handles_nested_content(self): +        doc = self.db.create_doc_from_json(nested_doc) +        self.assertGetDoc(self.db, doc.doc_id, doc.rev, nested_doc, False) + +    def test_handles_doc_with_null(self): +        doc = self.db.create_doc_from_json('{"key": null}') +        self.assertGetDoc(self.db, doc.doc_id, doc.rev, '{"key": null}', False) + +    def test_delete_doc(self): +        doc = self.db.create_doc_from_json(simple_doc) +        self.assertGetDoc(self.db, doc.doc_id, doc.rev, simple_doc, False) +        orig_rev = doc.rev +        self.db.delete_doc(doc) +        self.assertNotEqual(orig_rev, doc.rev) +        self.assertGetDocIncludeDeleted( +            self.db, doc.doc_id, doc.rev, None, False) +        self.assertIs(None, self.db.get_doc(doc.doc_id)) + +    def test_delete_doc_non_existent(self): +        doc = self.make_document('non-existing', 'other:1', simple_doc) +        self.assertRaises(errors.DocumentDoesNotExist, self.db.delete_doc, doc) + +    def test_delete_doc_already_deleted(self): +        doc = self.db.create_doc_from_json(simple_doc) +        self.db.delete_doc(doc) +        self.assertRaises(errors.DocumentAlreadyDeleted, +                          self.db.delete_doc, doc) +        self.assertGetDocIncludeDeleted( +            self.db, doc.doc_id, doc.rev, None, False) + +    def test_delete_doc_bad_rev(self): +        doc1 = self.db.create_doc_from_json(simple_doc) +        self.assertGetDoc(self.db, doc1.doc_id, doc1.rev, simple_doc, False) +        doc2 = self.make_document(doc1.doc_id, 'other:1', simple_doc) +        self.assertRaises(errors.RevisionConflict, self.db.delete_doc, doc2) +        self.assertGetDoc(self.db, doc1.doc_id, doc1.rev, simple_doc, False) + +    def test_delete_doc_sets_content_to_None(self): +        doc = self.db.create_doc_from_json(simple_doc) +        self.db.delete_doc(doc) +        self.assertIs(None, doc.get_json()) + +    def test_delete_doc_rev_supersedes(self): +        doc = self.db.create_doc_from_json(simple_doc) +        doc.set_json(nested_doc) +        self.db.put_doc(doc) +        doc.set_json('{"fishy": "content"}') +        self.db.put_doc(doc) +        old_rev = doc.rev +        self.db.delete_doc(doc) +        cur_vc = vectorclock.VectorClockRev(old_rev) +        deleted_vc = vectorclock.VectorClockRev(doc.rev) +        self.assertTrue(deleted_vc.is_newer(cur_vc), +                        "%s does not supersede %s" % (doc.rev, old_rev)) + +    def test_delete_then_put(self): +        doc = self.db.create_doc_from_json(simple_doc) +        self.db.delete_doc(doc) +        self.assertGetDocIncludeDeleted( +            self.db, doc.doc_id, doc.rev, None, False) +        doc.set_json(nested_doc) +        self.db.put_doc(doc) +        self.assertGetDoc(self.db, doc.doc_id, doc.rev, nested_doc, False) + + +class DocumentSizeTests(tests.DatabaseBaseTests): + +    scenarios = tests.LOCAL_DATABASES_SCENARIOS + +    def test_put_doc_refuses_oversized_documents(self): +        self.db.set_document_size_limit(1) +        doc = self.make_document('doc-id', None, simple_doc) +        self.assertRaises(errors.DocumentTooBig, self.db.put_doc, doc) + +    def test_create_doc_refuses_oversized_documents(self): +        self.db.set_document_size_limit(1) +        self.assertRaises( +            errors.DocumentTooBig, self.db.create_doc_from_json, simple_doc, +            doc_id='my_doc_id') + +    def test_set_document_size_limit_zero(self): +        self.db.set_document_size_limit(0) +        self.assertEqual(0, self.db.document_size_limit) + +    def test_set_document_size_limit(self): +        self.db.set_document_size_limit(1000000) +        self.assertEqual(1000000, self.db.document_size_limit) + + +class LocalDatabaseTests(tests.DatabaseBaseTests): + +    scenarios = tests.LOCAL_DATABASES_SCENARIOS + +    def test_create_doc_different_ids_diff_db(self): +        doc1 = self.db.create_doc_from_json(simple_doc) +        db2 = self.create_database('other-uid') +        doc2 = db2.create_doc_from_json(simple_doc) +        self.assertNotEqual(doc1.doc_id, doc2.doc_id) + +    def test_put_doc_refuses_slashes_picky(self): +        doc = self.make_document('/a', None, simple_doc) +        self.assertRaises(errors.InvalidDocId, self.db.put_doc, doc) + +    def test_get_all_docs_empty(self): +        self.assertEqual([], list(self.db.get_all_docs()[1])) + +    def test_get_all_docs(self): +        doc1 = self.db.create_doc_from_json(simple_doc) +        doc2 = self.db.create_doc_from_json(nested_doc) +        self.assertEqual( +            sorted([doc1, doc2]), sorted(list(self.db.get_all_docs()[1]))) + +    def test_get_all_docs_exclude_deleted(self): +        doc1 = self.db.create_doc_from_json(simple_doc) +        doc2 = self.db.create_doc_from_json(nested_doc) +        self.db.delete_doc(doc2) +        self.assertEqual([doc1], list(self.db.get_all_docs()[1])) + +    def test_get_all_docs_include_deleted(self): +        doc1 = self.db.create_doc_from_json(simple_doc) +        doc2 = self.db.create_doc_from_json(nested_doc) +        self.db.delete_doc(doc2) +        self.assertEqual( +            sorted([doc1, doc2]), +            sorted(list(self.db.get_all_docs(include_deleted=True)[1]))) + +    def test_get_all_docs_generation(self): +        self.db.create_doc_from_json(simple_doc) +        self.db.create_doc_from_json(nested_doc) +        self.assertEqual(2, self.db.get_all_docs()[0]) + +    def test_simple_put_doc_if_newer(self): +        doc = self.make_document('my-doc-id', 'test:1', simple_doc) +        state_at_gen = self.db._put_doc_if_newer( +            doc, save_conflict=False, replica_uid='r', replica_gen=1, +            replica_trans_id='foo') +        self.assertEqual(('inserted', 1), state_at_gen) +        self.assertGetDoc(self.db, 'my-doc-id', 'test:1', simple_doc, False) + +    def test_simple_put_doc_if_newer_deleted(self): +        self.db.create_doc_from_json('{}', doc_id='my-doc-id') +        doc = self.make_document('my-doc-id', 'test:2', None) +        state_at_gen = self.db._put_doc_if_newer( +            doc, save_conflict=False, replica_uid='r', replica_gen=1, +            replica_trans_id='foo') +        self.assertEqual(('inserted', 2), state_at_gen) +        self.assertGetDocIncludeDeleted( +            self.db, 'my-doc-id', 'test:2', None, False) + +    def test_put_doc_if_newer_already_superseded(self): +        orig_doc = '{"new": "doc"}' +        doc1 = self.db.create_doc_from_json(orig_doc) +        doc1_rev1 = doc1.rev +        doc1.set_json(simple_doc) +        self.db.put_doc(doc1) +        doc1_rev2 = doc1.rev +        # Nothing is inserted, because the document is already superseded +        doc = self.make_document(doc1.doc_id, doc1_rev1, orig_doc) +        state, _ = self.db._put_doc_if_newer( +            doc, save_conflict=False, replica_uid='r', replica_gen=1, +            replica_trans_id='foo') +        self.assertEqual('superseded', state) +        self.assertGetDoc(self.db, doc1.doc_id, doc1_rev2, simple_doc, False) + +    def test_put_doc_if_newer_autoresolve(self): +        doc1 = self.db.create_doc_from_json(simple_doc) +        rev = doc1.rev +        doc = self.make_document(doc1.doc_id, "whatever:1", doc1.get_json()) +        state, _ = self.db._put_doc_if_newer( +            doc, save_conflict=False, replica_uid='r', replica_gen=1, +            replica_trans_id='foo') +        self.assertEqual('superseded', state) +        doc2 = self.db.get_doc(doc1.doc_id) +        v2 = vectorclock.VectorClockRev(doc2.rev) +        self.assertTrue(v2.is_newer(vectorclock.VectorClockRev("whatever:1"))) +        self.assertTrue(v2.is_newer(vectorclock.VectorClockRev(rev))) +        # strictly newer locally +        self.assertTrue(rev not in doc2.rev) + +    def test_put_doc_if_newer_already_converged(self): +        orig_doc = '{"new": "doc"}' +        doc1 = self.db.create_doc_from_json(orig_doc) +        state_at_gen = self.db._put_doc_if_newer( +            doc1, save_conflict=False, replica_uid='r', replica_gen=1, +            replica_trans_id='foo') +        self.assertEqual(('converged', 1), state_at_gen) + +    def test_put_doc_if_newer_conflicted(self): +        doc1 = self.db.create_doc_from_json(simple_doc) +        # Nothing is inserted, the document id is returned as would-conflict +        alt_doc = self.make_document(doc1.doc_id, 'alternate:1', nested_doc) +        state, _ = self.db._put_doc_if_newer( +            alt_doc, save_conflict=False, replica_uid='r', replica_gen=1, +            replica_trans_id='foo') +        self.assertEqual('conflicted', state) +        # The database wasn't altered +        self.assertGetDoc(self.db, doc1.doc_id, doc1.rev, simple_doc, False) + +    def test_put_doc_if_newer_newer_generation(self): +        self.db._set_replica_gen_and_trans_id('other', 1, 'T-sid') +        doc = self.make_document('doc_id', 'other:2', simple_doc) +        state, _ = self.db._put_doc_if_newer( +            doc, save_conflict=False, replica_uid='other', replica_gen=2, +            replica_trans_id='T-irrelevant') +        self.assertEqual('inserted', state) + +    def test_put_doc_if_newer_same_generation_same_txid(self): +        self.db._set_replica_gen_and_trans_id('other', 1, 'T-sid') +        doc = self.db.create_doc_from_json(simple_doc) +        self.make_document(doc.doc_id, 'other:1', simple_doc) +        state, _ = self.db._put_doc_if_newer( +            doc, save_conflict=False, replica_uid='other', replica_gen=1, +            replica_trans_id='T-sid') +        self.assertEqual('converged', state) + +    def test_put_doc_if_newer_wrong_transaction_id(self): +        self.db._set_replica_gen_and_trans_id('other', 1, 'T-sid') +        doc = self.make_document('doc_id', 'other:1', simple_doc) +        self.assertRaises( +            errors.InvalidTransactionId, +            self.db._put_doc_if_newer, doc, save_conflict=False, +            replica_uid='other', replica_gen=1, replica_trans_id='T-sad') + +    def test_put_doc_if_newer_old_generation_older_doc(self): +        orig_doc = '{"new": "doc"}' +        doc = self.db.create_doc_from_json(orig_doc) +        doc_rev1 = doc.rev +        doc.set_json(simple_doc) +        self.db.put_doc(doc) +        self.db._set_replica_gen_and_trans_id('other', 3, 'T-sid') +        older_doc = self.make_document(doc.doc_id, doc_rev1, simple_doc) +        state, _ = self.db._put_doc_if_newer( +            older_doc, save_conflict=False, replica_uid='other', replica_gen=8, +            replica_trans_id='T-irrelevant') +        self.assertEqual('superseded', state) + +    def test_put_doc_if_newer_old_generation_newer_doc(self): +        self.db._set_replica_gen_and_trans_id('other', 5, 'T-sid') +        doc = self.make_document('doc_id', 'other:1', simple_doc) +        self.assertRaises( +            errors.InvalidGeneration, +            self.db._put_doc_if_newer, doc, save_conflict=False, +            replica_uid='other', replica_gen=1, replica_trans_id='T-sad') + +    def test_put_doc_if_newer_replica_uid(self): +        doc1 = self.db.create_doc_from_json(simple_doc) +        self.db._set_replica_gen_and_trans_id('other', 1, 'T-sid') +        doc2 = self.make_document(doc1.doc_id, doc1.rev + '|other:1', +                                  nested_doc) +        self.assertEqual('inserted', +                         self.db._put_doc_if_newer( +                             doc2, +                             save_conflict=False, +                             replica_uid='other', +                             replica_gen=2, +                             replica_trans_id='T-id2')[0]) +        self.assertEqual((2, 'T-id2'), self.db._get_replica_gen_and_trans_id( +            'other')) +        # Compare to the old rev, should be superseded +        doc2 = self.make_document(doc1.doc_id, doc1.rev, nested_doc) +        self.assertEqual('superseded', +                         self.db._put_doc_if_newer( +                             doc2, +                             save_conflict=False, +                             replica_uid='other', +                             replica_gen=3, +                             replica_trans_id='T-id3')[0]) +        self.assertEqual( +            (3, 'T-id3'), self.db._get_replica_gen_and_trans_id('other')) +        # A conflict that isn't saved still records the sync gen, because we +        # don't need to see it again +        doc2 = self.make_document(doc1.doc_id, doc1.rev + '|fourth:1', +                                  '{}') +        self.assertEqual('conflicted', +                         self.db._put_doc_if_newer( +                             doc2, +                             save_conflict=False, +                             replica_uid='other', +                             replica_gen=4, +                             replica_trans_id='T-id4')[0]) +        self.assertEqual( +            (4, 'T-id4'), self.db._get_replica_gen_and_trans_id('other')) + +    def test__get_replica_gen_and_trans_id(self): +        self.assertEqual( +            (0, ''), self.db._get_replica_gen_and_trans_id('other-db')) +        self.db._set_replica_gen_and_trans_id('other-db', 2, 'T-transaction') +        self.assertEqual( +            (2, 'T-transaction'), +            self.db._get_replica_gen_and_trans_id('other-db')) + +    def test_put_updates_transaction_log(self): +        doc = self.db.create_doc_from_json(simple_doc) +        self.assertTransactionLog([doc.doc_id], self.db) +        doc.set_json('{"something": "else"}') +        self.db.put_doc(doc) +        self.assertTransactionLog([doc.doc_id, doc.doc_id], self.db) +        last_trans_id = self.getLastTransId(self.db) +        self.assertEqual((2, last_trans_id, [(doc.doc_id, 2, last_trans_id)]), +                         self.db.whats_changed()) + +    def test_delete_updates_transaction_log(self): +        doc = self.db.create_doc_from_json(simple_doc) +        db_gen, _, _ = self.db.whats_changed() +        self.db.delete_doc(doc) +        last_trans_id = self.getLastTransId(self.db) +        self.assertEqual((2, last_trans_id, [(doc.doc_id, 2, last_trans_id)]), +                         self.db.whats_changed(db_gen)) + +    def test_whats_changed_initial_database(self): +        self.assertEqual((0, '', []), self.db.whats_changed()) + +    def test_whats_changed_returns_one_id_for_multiple_changes(self): +        doc = self.db.create_doc_from_json(simple_doc) +        doc.set_json('{"new": "contents"}') +        self.db.put_doc(doc) +        last_trans_id = self.getLastTransId(self.db) +        self.assertEqual((2, last_trans_id, [(doc.doc_id, 2, last_trans_id)]), +                         self.db.whats_changed()) +        self.assertEqual((2, last_trans_id, []), self.db.whats_changed(2)) + +    def test_whats_changed_returns_last_edits_ascending(self): +        doc = self.db.create_doc_from_json(simple_doc) +        doc1 = self.db.create_doc_from_json(simple_doc) +        doc.set_json('{"new": "contents"}') +        self.db.delete_doc(doc1) +        delete_trans_id = self.getLastTransId(self.db) +        self.db.put_doc(doc) +        put_trans_id = self.getLastTransId(self.db) +        self.assertEqual((4, put_trans_id, +                          [(doc1.doc_id, 3, delete_trans_id), +                           (doc.doc_id, 4, put_trans_id)]), +                         self.db.whats_changed()) + +    def test_whats_changed_doesnt_include_old_gen(self): +        self.db.create_doc_from_json(simple_doc) +        self.db.create_doc_from_json(simple_doc) +        doc2 = self.db.create_doc_from_json(simple_doc) +        last_trans_id = self.getLastTransId(self.db) +        self.assertEqual((3, last_trans_id, [(doc2.doc_id, 3, last_trans_id)]), +                         self.db.whats_changed(2)) + + +class LocalDatabaseValidateGenNTransIdTests(tests.DatabaseBaseTests): + +    scenarios = tests.LOCAL_DATABASES_SCENARIOS + +    def test_validate_gen_and_trans_id(self): +        self.db.create_doc_from_json(simple_doc) +        gen, trans_id = self.db._get_generation_info() +        self.db.validate_gen_and_trans_id(gen, trans_id) + +    def test_validate_gen_and_trans_id_invalid_txid(self): +        self.db.create_doc_from_json(simple_doc) +        gen, _ = self.db._get_generation_info() +        self.assertRaises( +            errors.InvalidTransactionId, +            self.db.validate_gen_and_trans_id, gen, 'wrong') + +    def test_validate_gen_and_trans_id_invalid_gen(self): +        self.db.create_doc_from_json(simple_doc) +        gen, trans_id = self.db._get_generation_info() +        self.assertRaises( +            errors.InvalidGeneration, +            self.db.validate_gen_and_trans_id, gen + 1, trans_id) + + +class LocalDatabaseValidateSourceGenTests(tests.DatabaseBaseTests): + +    scenarios = tests.LOCAL_DATABASES_SCENARIOS + +    def test_validate_source_gen_and_trans_id_same(self): +        self.db._set_replica_gen_and_trans_id('other', 1, 'T-sid') +        self.db._validate_source('other', 1, 'T-sid') + +    def test_validate_source_gen_newer(self): +        self.db._set_replica_gen_and_trans_id('other', 1, 'T-sid') +        self.db._validate_source('other', 2, 'T-whatevs') + +    def test_validate_source_wrong_txid(self): +        self.db._set_replica_gen_and_trans_id('other', 1, 'T-sid') +        self.assertRaises( +            errors.InvalidTransactionId, +            self.db._validate_source, 'other', 1, 'T-sad') + + +class LocalDatabaseWithConflictsTests(tests.DatabaseBaseTests): +    # test supporting/functionality around storing conflicts + +    scenarios = tests.LOCAL_DATABASES_SCENARIOS + +    def test_get_docs_conflicted(self): +        doc1 = self.db.create_doc_from_json(simple_doc) +        doc2 = self.make_document(doc1.doc_id, 'alternate:1', nested_doc) +        self.db._put_doc_if_newer( +            doc2, save_conflict=True, replica_uid='r', replica_gen=1, +            replica_trans_id='foo') +        self.assertEqual([doc2], list(self.db.get_docs([doc1.doc_id]))) + +    def test_get_docs_conflicts_ignored(self): +        doc1 = self.db.create_doc_from_json(simple_doc) +        doc2 = self.db.create_doc_from_json(nested_doc) +        alt_doc = self.make_document(doc1.doc_id, 'alternate:1', nested_doc) +        self.db._put_doc_if_newer( +            alt_doc, save_conflict=True, replica_uid='r', replica_gen=1, +            replica_trans_id='foo') +        no_conflict_doc = self.make_document(doc1.doc_id, 'alternate:1', +                                             nested_doc) +        self.assertEqual([no_conflict_doc, doc2], +                         list(self.db.get_docs([doc1.doc_id, doc2.doc_id], +                                               check_for_conflicts=False))) + +    def test_get_doc_conflicts(self): +        doc = self.db.create_doc_from_json(simple_doc) +        alt_doc = self.make_document(doc.doc_id, 'alternate:1', nested_doc) +        self.db._put_doc_if_newer( +            alt_doc, save_conflict=True, replica_uid='r', replica_gen=1, +            replica_trans_id='foo') +        self.assertEqual([alt_doc, doc], +                         self.db.get_doc_conflicts(doc.doc_id)) + +    def test_get_all_docs_sees_conflicts(self): +        doc = self.db.create_doc_from_json(simple_doc) +        alt_doc = self.make_document(doc.doc_id, 'alternate:1', nested_doc) +        self.db._put_doc_if_newer( +            alt_doc, save_conflict=True, replica_uid='r', replica_gen=1, +            replica_trans_id='foo') +        _, docs = self.db.get_all_docs() +        self.assertTrue(list(docs)[0].has_conflicts) + +    def test_get_doc_conflicts_unconflicted(self): +        doc = self.db.create_doc_from_json(simple_doc) +        self.assertEqual([], self.db.get_doc_conflicts(doc.doc_id)) + +    def test_get_doc_conflicts_no_such_id(self): +        self.assertEqual([], self.db.get_doc_conflicts('doc-id')) + +    def test_resolve_doc(self): +        doc = self.db.create_doc_from_json(simple_doc) +        alt_doc = self.make_document(doc.doc_id, 'alternate:1', nested_doc) +        self.db._put_doc_if_newer( +            alt_doc, save_conflict=True, replica_uid='r', replica_gen=1, +            replica_trans_id='foo') +        self.assertGetDocConflicts(self.db, doc.doc_id, +                                   [('alternate:1', nested_doc), +                                    (doc.rev, simple_doc)]) +        orig_rev = doc.rev +        self.db.resolve_doc(doc, [alt_doc.rev, doc.rev]) +        self.assertNotEqual(orig_rev, doc.rev) +        self.assertFalse(doc.has_conflicts) +        self.assertGetDoc(self.db, doc.doc_id, doc.rev, simple_doc, False) +        self.assertGetDocConflicts(self.db, doc.doc_id, []) + +    def test_resolve_doc_picks_biggest_vcr(self): +        doc1 = self.db.create_doc_from_json(simple_doc) +        doc2 = self.make_document(doc1.doc_id, 'alternate:1', nested_doc) +        self.db._put_doc_if_newer( +            doc2, save_conflict=True, replica_uid='r', replica_gen=1, +            replica_trans_id='foo') +        self.assertGetDocConflicts(self.db, doc1.doc_id, +                                   [(doc2.rev, nested_doc), +                                    (doc1.rev, simple_doc)]) +        orig_doc1_rev = doc1.rev +        self.db.resolve_doc(doc1, [doc2.rev, doc1.rev]) +        self.assertFalse(doc1.has_conflicts) +        self.assertNotEqual(orig_doc1_rev, doc1.rev) +        self.assertGetDoc(self.db, doc1.doc_id, doc1.rev, simple_doc, False) +        self.assertGetDocConflicts(self.db, doc1.doc_id, []) +        vcr_1 = vectorclock.VectorClockRev(orig_doc1_rev) +        vcr_2 = vectorclock.VectorClockRev(doc2.rev) +        vcr_new = vectorclock.VectorClockRev(doc1.rev) +        self.assertTrue(vcr_new.is_newer(vcr_1)) +        self.assertTrue(vcr_new.is_newer(vcr_2)) + +    def test_resolve_doc_partial_not_winning(self): +        doc1 = self.db.create_doc_from_json(simple_doc) +        doc2 = self.make_document(doc1.doc_id, 'alternate:1', nested_doc) +        self.db._put_doc_if_newer( +            doc2, save_conflict=True, replica_uid='r', replica_gen=1, +            replica_trans_id='foo') +        self.assertGetDocConflicts(self.db, doc1.doc_id, +                                   [(doc2.rev, nested_doc), +                                    (doc1.rev, simple_doc)]) +        content3 = '{"key": "valin3"}' +        doc3 = self.make_document(doc1.doc_id, 'third:1', content3) +        self.db._put_doc_if_newer( +            doc3, save_conflict=True, replica_uid='r', replica_gen=2, +            replica_trans_id='bar') +        self.assertGetDocConflicts(self.db, doc1.doc_id, +                                   [(doc3.rev, content3), +                                    (doc1.rev, simple_doc), +                                    (doc2.rev, nested_doc)]) +        self.db.resolve_doc(doc1, [doc2.rev, doc1.rev]) +        self.assertTrue(doc1.has_conflicts) +        self.assertGetDoc(self.db, doc1.doc_id, doc3.rev, content3, True) +        self.assertGetDocConflicts(self.db, doc1.doc_id, +                                   [(doc3.rev, content3), +                                    (doc1.rev, simple_doc)]) + +    def test_resolve_doc_partial_winning(self): +        doc1 = self.db.create_doc_from_json(simple_doc) +        doc2 = self.make_document(doc1.doc_id, 'alternate:1', nested_doc) +        self.db._put_doc_if_newer( +            doc2, save_conflict=True, replica_uid='r', replica_gen=1, +            replica_trans_id='foo') +        content3 = '{"key": "valin3"}' +        doc3 = self.make_document(doc1.doc_id, 'third:1', content3) +        self.db._put_doc_if_newer( +            doc3, save_conflict=True, replica_uid='r', replica_gen=2, +            replica_trans_id='bar') +        self.assertGetDocConflicts(self.db, doc1.doc_id, +                                   [(doc3.rev, content3), +                                    (doc1.rev, simple_doc), +                                    (doc2.rev, nested_doc)]) +        self.db.resolve_doc(doc1, [doc3.rev, doc1.rev]) +        self.assertTrue(doc1.has_conflicts) +        self.assertGetDocConflicts(self.db, doc1.doc_id, +                                   [(doc1.rev, simple_doc), +                                    (doc2.rev, nested_doc)]) + +    def test_resolve_doc_with_delete_conflict(self): +        doc1 = self.db.create_doc_from_json(simple_doc) +        self.db.delete_doc(doc1) +        doc2 = self.make_document(doc1.doc_id, 'alternate:1', nested_doc) +        self.db._put_doc_if_newer( +            doc2, save_conflict=True, replica_uid='r', replica_gen=1, +            replica_trans_id='foo') +        self.assertGetDocConflicts(self.db, doc1.doc_id, +                                   [(doc2.rev, nested_doc), +                                    (doc1.rev, None)]) +        self.db.resolve_doc(doc2, [doc1.rev, doc2.rev]) +        self.assertGetDocConflicts(self.db, doc1.doc_id, []) +        self.assertGetDoc(self.db, doc2.doc_id, doc2.rev, nested_doc, False) + +    def test_resolve_doc_with_delete_to_delete(self): +        doc1 = self.db.create_doc_from_json(simple_doc) +        self.db.delete_doc(doc1) +        doc2 = self.make_document(doc1.doc_id, 'alternate:1', nested_doc) +        self.db._put_doc_if_newer( +            doc2, save_conflict=True, replica_uid='r', replica_gen=1, +            replica_trans_id='foo') +        self.assertGetDocConflicts(self.db, doc1.doc_id, +                                   [(doc2.rev, nested_doc), +                                    (doc1.rev, None)]) +        self.db.resolve_doc(doc1, [doc1.rev, doc2.rev]) +        self.assertGetDocConflicts(self.db, doc1.doc_id, []) +        self.assertGetDocIncludeDeleted( +            self.db, doc1.doc_id, doc1.rev, None, False) + +    def test_put_doc_if_newer_save_conflicted(self): +        doc1 = self.db.create_doc_from_json(simple_doc) +        # Document is inserted as a conflict +        doc2 = self.make_document(doc1.doc_id, 'alternate:1', nested_doc) +        state, _ = self.db._put_doc_if_newer( +            doc2, save_conflict=True, replica_uid='r', replica_gen=1, +            replica_trans_id='foo') +        self.assertEqual('conflicted', state) +        # The database was updated +        self.assertGetDoc(self.db, doc1.doc_id, doc2.rev, nested_doc, True) + +    def test_force_doc_conflict_supersedes_properly(self): +        doc1 = self.db.create_doc_from_json(simple_doc) +        doc2 = self.make_document(doc1.doc_id, 'alternate:1', '{"b": 1}') +        self.db._put_doc_if_newer( +            doc2, save_conflict=True, replica_uid='r', replica_gen=1, +            replica_trans_id='foo') +        doc3 = self.make_document(doc1.doc_id, 'altalt:1', '{"c": 1}') +        self.db._put_doc_if_newer( +            doc3, save_conflict=True, replica_uid='r', replica_gen=2, +            replica_trans_id='bar') +        doc22 = self.make_document(doc1.doc_id, 'alternate:2', '{"b": 2}') +        self.db._put_doc_if_newer( +            doc22, save_conflict=True, replica_uid='r', replica_gen=3, +            replica_trans_id='zed') +        self.assertGetDocConflicts(self.db, doc1.doc_id, +                                   [('alternate:2', doc22.get_json()), +                                    ('altalt:1', doc3.get_json()), +                                    (doc1.rev, simple_doc)]) + +    def test_put_doc_if_newer_save_conflict_was_deleted(self): +        doc1 = self.db.create_doc_from_json(simple_doc) +        self.db.delete_doc(doc1) +        doc2 = self.make_document(doc1.doc_id, 'alternate:1', nested_doc) +        self.db._put_doc_if_newer( +            doc2, save_conflict=True, replica_uid='r', replica_gen=1, +            replica_trans_id='foo') +        self.assertTrue(doc2.has_conflicts) +        self.assertGetDoc( +            self.db, doc1.doc_id, 'alternate:1', nested_doc, True) +        self.assertGetDocConflicts(self.db, doc1.doc_id, +                                   [('alternate:1', nested_doc), +                                    (doc1.rev, None)]) + +    def test_put_doc_if_newer_propagates_full_resolution(self): +        doc1 = self.db.create_doc_from_json(simple_doc) +        doc2 = self.make_document(doc1.doc_id, 'alternate:1', nested_doc) +        self.db._put_doc_if_newer( +            doc2, save_conflict=True, replica_uid='r', replica_gen=1, +            replica_trans_id='foo') +        resolved_vcr = vectorclock.VectorClockRev(doc1.rev) +        vcr_2 = vectorclock.VectorClockRev(doc2.rev) +        resolved_vcr.maximize(vcr_2) +        resolved_vcr.increment('alternate') +        doc_resolved = self.make_document(doc1.doc_id, resolved_vcr.as_str(), +                                          '{"good": 1}') +        state, _ = self.db._put_doc_if_newer( +            doc_resolved, save_conflict=True, replica_uid='r', replica_gen=2, +            replica_trans_id='foo2') +        self.assertEqual('inserted', state) +        self.assertFalse(doc_resolved.has_conflicts) +        self.assertGetDocConflicts(self.db, doc1.doc_id, []) +        doc3 = self.db.get_doc(doc1.doc_id) +        self.assertFalse(doc3.has_conflicts) + +    def test_put_doc_if_newer_propagates_partial_resolution(self): +        doc1 = self.db.create_doc_from_json(simple_doc) +        doc2 = self.make_document(doc1.doc_id, 'altalt:1', '{}') +        self.db._put_doc_if_newer( +            doc2, save_conflict=True, replica_uid='r', replica_gen=1, +            replica_trans_id='foo') +        doc3 = self.make_document(doc1.doc_id, 'alternate:1', nested_doc) +        self.db._put_doc_if_newer( +            doc3, save_conflict=True, replica_uid='r', replica_gen=2, +            replica_trans_id='foo2') +        self.assertGetDocConflicts(self.db, doc1.doc_id, +                                   [('alternate:1', nested_doc), +                                    ('test:1', simple_doc), +                                    ('altalt:1', '{}')]) +        resolved_vcr = vectorclock.VectorClockRev(doc1.rev) +        vcr_3 = vectorclock.VectorClockRev(doc3.rev) +        resolved_vcr.maximize(vcr_3) +        resolved_vcr.increment('alternate') +        doc_resolved = self.make_document(doc1.doc_id, resolved_vcr.as_str(), +                                          '{"good": 1}') +        state, _ = self.db._put_doc_if_newer( +            doc_resolved, save_conflict=True, replica_uid='r', replica_gen=3, +            replica_trans_id='foo3') +        self.assertEqual('inserted', state) +        self.assertTrue(doc_resolved.has_conflicts) +        doc4 = self.db.get_doc(doc1.doc_id) +        self.assertTrue(doc4.has_conflicts) +        self.assertGetDocConflicts(self.db, doc1.doc_id, +                                   [('alternate:2|test:1', '{"good": 1}'), +                                    ('altalt:1', '{}')]) + +    def test_put_doc_if_newer_replica_uid(self): +        doc1 = self.db.create_doc_from_json(simple_doc) +        self.db._set_replica_gen_and_trans_id('other', 1, 'T-id') +        doc2 = self.make_document(doc1.doc_id, doc1.rev + '|other:1', +                                  nested_doc) +        self.db._put_doc_if_newer(doc2, save_conflict=True, +                                  replica_uid='other', replica_gen=2, +                                  replica_trans_id='T-id2') +        # Conflict vs the current update +        doc2 = self.make_document(doc1.doc_id, doc1.rev + '|third:3', +                                  '{}') +        self.assertEqual('conflicted', +                         self.db._put_doc_if_newer( +                             doc2, +                             save_conflict=True, +                             replica_uid='other', +                             replica_gen=3, +                             replica_trans_id='T-id3')[0]) +        self.assertEqual( +            (3, 'T-id3'), self.db._get_replica_gen_and_trans_id('other')) + +    def test_put_doc_if_newer_autoresolve_2(self): +        # this is an ordering variant of _3, but that already works +        # adding the test explicitly to catch the regression easily +        doc_a1 = self.db.create_doc_from_json(simple_doc) +        doc_a2 = self.make_document(doc_a1.doc_id, 'test:2', "{}") +        doc_a1b1 = self.make_document(doc_a1.doc_id, 'test:1|other:1', +                                      '{"a":"42"}') +        doc_a3 = self.make_document(doc_a1.doc_id, 'test:2|other:1', "{}") +        state, _ = self.db._put_doc_if_newer( +            doc_a2, save_conflict=True, replica_uid='r', replica_gen=1, +            replica_trans_id='foo') +        self.assertEqual(state, 'inserted') +        state, _ = self.db._put_doc_if_newer( +            doc_a1b1, save_conflict=True, replica_uid='r', replica_gen=2, +            replica_trans_id='foo2') +        self.assertEqual(state, 'conflicted') +        state, _ = self.db._put_doc_if_newer( +            doc_a3, save_conflict=True, replica_uid='r', replica_gen=3, +            replica_trans_id='foo3') +        self.assertEqual(state, 'inserted') +        self.assertFalse(self.db.get_doc(doc_a1.doc_id).has_conflicts) + +    def test_put_doc_if_newer_autoresolve_3(self): +        doc_a1 = self.db.create_doc_from_json(simple_doc) +        doc_a1b1 = self.make_document(doc_a1.doc_id, 'test:1|other:1', "{}") +        doc_a2 = self.make_document(doc_a1.doc_id, 'test:2', '{"a":"42"}') +        doc_a3 = self.make_document(doc_a1.doc_id, 'test:3', "{}") +        state, _ = self.db._put_doc_if_newer( +            doc_a1b1, save_conflict=True, replica_uid='r', replica_gen=1, +            replica_trans_id='foo') +        self.assertEqual(state, 'inserted') +        state, _ = self.db._put_doc_if_newer( +            doc_a2, save_conflict=True, replica_uid='r', replica_gen=2, +            replica_trans_id='foo2') +        self.assertEqual(state, 'conflicted') +        state, _ = self.db._put_doc_if_newer( +            doc_a3, save_conflict=True, replica_uid='r', replica_gen=3, +            replica_trans_id='foo3') +        self.assertEqual(state, 'superseded') +        doc = self.db.get_doc(doc_a1.doc_id, True) +        self.assertFalse(doc.has_conflicts) +        rev = vectorclock.VectorClockRev(doc.rev) +        rev_a3 = vectorclock.VectorClockRev('test:3') +        rev_a1b1 = vectorclock.VectorClockRev('test:1|other:1') +        self.assertTrue(rev.is_newer(rev_a3)) +        self.assertTrue('test:4' in doc.rev)  # locally increased +        self.assertTrue(rev.is_newer(rev_a1b1)) + +    def test_put_doc_if_newer_autoresolve_4(self): +        doc_a1 = self.db.create_doc_from_json(simple_doc) +        doc_a1b1 = self.make_document(doc_a1.doc_id, 'test:1|other:1', None) +        doc_a2 = self.make_document(doc_a1.doc_id, 'test:2', '{"a":"42"}') +        doc_a3 = self.make_document(doc_a1.doc_id, 'test:3', None) +        state, _ = self.db._put_doc_if_newer( +            doc_a1b1, save_conflict=True, replica_uid='r', replica_gen=1, +            replica_trans_id='foo') +        self.assertEqual(state, 'inserted') +        state, _ = self.db._put_doc_if_newer( +            doc_a2, save_conflict=True, replica_uid='r', replica_gen=2, +            replica_trans_id='foo2') +        self.assertEqual(state, 'conflicted') +        state, _ = self.db._put_doc_if_newer( +            doc_a3, save_conflict=True, replica_uid='r', replica_gen=3, +            replica_trans_id='foo3') +        self.assertEqual(state, 'superseded') +        doc = self.db.get_doc(doc_a1.doc_id, True) +        self.assertFalse(doc.has_conflicts) +        rev = vectorclock.VectorClockRev(doc.rev) +        rev_a3 = vectorclock.VectorClockRev('test:3') +        rev_a1b1 = vectorclock.VectorClockRev('test:1|other:1') +        self.assertTrue(rev.is_newer(rev_a3)) +        self.assertTrue('test:4' in doc.rev)  # locally increased +        self.assertTrue(rev.is_newer(rev_a1b1)) + +    def test_put_refuses_to_update_conflicted(self): +        doc1 = self.db.create_doc_from_json(simple_doc) +        content2 = '{"key": "altval"}' +        doc2 = self.make_document(doc1.doc_id, 'altrev:1', content2) +        self.db._put_doc_if_newer( +            doc2, save_conflict=True, replica_uid='r', replica_gen=1, +            replica_trans_id='foo') +        self.assertGetDoc(self.db, doc1.doc_id, doc2.rev, content2, True) +        content3 = '{"key": "local"}' +        doc2.set_json(content3) +        self.assertRaises(errors.ConflictedDoc, self.db.put_doc, doc2) + +    def test_delete_refuses_for_conflicted(self): +        doc1 = self.db.create_doc_from_json(simple_doc) +        doc2 = self.make_document(doc1.doc_id, 'altrev:1', nested_doc) +        self.db._put_doc_if_newer( +            doc2, save_conflict=True, replica_uid='r', replica_gen=1, +            replica_trans_id='foo') +        self.assertGetDoc(self.db, doc2.doc_id, doc2.rev, nested_doc, True) +        self.assertRaises(errors.ConflictedDoc, self.db.delete_doc, doc2) + + +class DatabaseIndexTests(tests.DatabaseBaseTests): + +    scenarios = tests.LOCAL_DATABASES_SCENARIOS + +    def assertParseError(self, definition): +        self.db.create_doc_from_json(nested_doc) +        self.assertRaises( +            errors.IndexDefinitionParseError, self.db.create_index, 'idx', +            definition) + +    def assertIndexCreatable(self, definition): +        name = "idx" +        self.db.create_doc_from_json(nested_doc) +        self.db.create_index(name, definition) +        self.assertEqual( +            [(name, [definition])], self.db.list_indexes()) + +    def test_create_index(self): +        self.db.create_index('test-idx', 'name') +        self.assertEqual([('test-idx', ['name'])], +                         self.db.list_indexes()) + +    def test_create_index_on_non_ascii_field_name(self): +        doc = self.db.create_doc_from_json(json.dumps({u'\xe5': 'value'})) +        self.db.create_index('test-idx', u'\xe5') +        self.assertEqual([doc], self.db.get_from_index('test-idx', 'value')) + +    def test_list_indexes_with_non_ascii_field_names(self): +        self.db.create_index('test-idx', u'\xe5') +        self.assertEqual( +            [('test-idx', [u'\xe5'])], self.db.list_indexes()) + +    def test_create_index_evaluates_it(self): +        doc = self.db.create_doc_from_json(simple_doc) +        self.db.create_index('test-idx', 'key') +        self.assertEqual([doc], self.db.get_from_index('test-idx', 'value')) + +    def test_wildcard_matches_unicode_value(self): +        doc = self.db.create_doc_from_json(json.dumps({"key": u"valu\xe5"})) +        self.db.create_index('test-idx', 'key') +        self.assertEqual([doc], self.db.get_from_index('test-idx', '*')) + +    def test_retrieve_unicode_value_from_index(self): +        doc = self.db.create_doc_from_json(json.dumps({"key": u"valu\xe5"})) +        self.db.create_index('test-idx', 'key') +        self.assertEqual( +            [doc], self.db.get_from_index('test-idx', u"valu\xe5")) + +    def test_create_index_fails_if_name_taken(self): +        self.db.create_index('test-idx', 'key') +        self.assertRaises(errors.IndexNameTakenError, +                          self.db.create_index, +                          'test-idx', 'stuff') + +    def test_create_index_does_not_fail_if_name_taken_with_same_index(self): +        self.db.create_index('test-idx', 'key') +        self.db.create_index('test-idx', 'key') +        self.assertEqual([('test-idx', ['key'])], self.db.list_indexes()) + +    def test_create_index_does_not_duplicate_indexed_fields(self): +        self.db.create_doc_from_json(simple_doc) +        self.db.create_index('test-idx', 'key') +        self.db.delete_index('test-idx') +        self.db.create_index('test-idx', 'key') +        self.assertEqual(1, len(self.db.get_from_index('test-idx', 'value'))) + +    def test_delete_index_does_not_remove_fields_from_other_indexes(self): +        self.db.create_doc_from_json(simple_doc) +        self.db.create_index('test-idx', 'key') +        self.db.create_index('test-idx2', 'key') +        self.db.delete_index('test-idx') +        self.assertEqual(1, len(self.db.get_from_index('test-idx2', 'value'))) + +    def test_create_index_after_deleting_document(self): +        doc = self.db.create_doc_from_json(simple_doc) +        doc2 = self.db.create_doc_from_json(simple_doc) +        self.db.delete_doc(doc2) +        self.db.create_index('test-idx', 'key') +        self.assertEqual([doc], self.db.get_from_index('test-idx', 'value')) + +    def test_delete_index(self): +        self.db.create_index('test-idx', 'key') +        self.assertEqual([('test-idx', ['key'])], self.db.list_indexes()) +        self.db.delete_index('test-idx') +        self.assertEqual([], self.db.list_indexes()) + +    def test_create_adds_to_index(self): +        self.db.create_index('test-idx', 'key') +        doc = self.db.create_doc_from_json(simple_doc) +        self.assertEqual([doc], self.db.get_from_index('test-idx', 'value')) + +    def test_get_from_index_unmatched(self): +        self.db.create_doc_from_json(simple_doc) +        self.db.create_index('test-idx', 'key') +        self.assertEqual([], self.db.get_from_index('test-idx', 'novalue')) + +    def test_create_index_multiple_exact_matches(self): +        doc = self.db.create_doc_from_json(simple_doc) +        doc2 = self.db.create_doc_from_json(simple_doc) +        self.db.create_index('test-idx', 'key') +        self.assertEqual( +            sorted([doc, doc2]), +            sorted(self.db.get_from_index('test-idx', 'value'))) + +    def test_get_from_index(self): +        doc = self.db.create_doc_from_json(simple_doc) +        self.db.create_index('test-idx', 'key') +        self.assertEqual([doc], self.db.get_from_index('test-idx', 'value')) + +    def test_get_from_index_multi(self): +        content = '{"key": "value", "key2": "value2"}' +        doc = self.db.create_doc_from_json(content) +        self.db.create_index('test-idx', 'key', 'key2') +        self.assertEqual( +            [doc], self.db.get_from_index('test-idx', 'value', 'value2')) + +    def test_get_from_index_multi_list(self): +        doc = self.db.create_doc_from_json( +            '{"key": "value", "key2": ["value2-1", "value2-2", "value2-3"]}') +        self.db.create_index('test-idx', 'key', 'key2') +        self.assertEqual( +            [doc], self.db.get_from_index('test-idx', 'value', 'value2-1')) +        self.assertEqual( +            [doc], self.db.get_from_index('test-idx', 'value', 'value2-2')) +        self.assertEqual( +            [doc], self.db.get_from_index('test-idx', 'value', 'value2-3')) +        self.assertEqual( +            [('value', 'value2-1'), ('value', 'value2-2'), +             ('value', 'value2-3')], +            sorted(self.db.get_index_keys('test-idx'))) + +    def test_get_from_index_sees_conflicts(self): +        doc = self.db.create_doc_from_json(simple_doc) +        self.db.create_index('test-idx', 'key', 'key2') +        alt_doc = self.make_document( +            doc.doc_id, 'alternate:1', +            '{"key": "value", "key2": ["value2-1", "value2-2", "value2-3"]}') +        self.db._put_doc_if_newer( +            alt_doc, save_conflict=True, replica_uid='r', replica_gen=1, +            replica_trans_id='foo') +        docs = self.db.get_from_index('test-idx', 'value', 'value2-1') +        self.assertTrue(docs[0].has_conflicts) + +    def test_get_index_keys_multi_list_list(self): +        self.db.create_doc_from_json( +            '{"key": "value1-1 value1-2 value1-3", ' +            '"key2": ["value2-1", "value2-2", "value2-3"]}') +        self.db.create_index('test-idx', 'split_words(key)', 'key2') +        self.assertEqual( +            [(u'value1-1', u'value2-1'), (u'value1-1', u'value2-2'), +             (u'value1-1', u'value2-3'), (u'value1-2', u'value2-1'), +             (u'value1-2', u'value2-2'), (u'value1-2', u'value2-3'), +             (u'value1-3', u'value2-1'), (u'value1-3', u'value2-2'), +             (u'value1-3', u'value2-3')], +            sorted(self.db.get_index_keys('test-idx'))) + +    def test_get_from_index_multi_ordered(self): +        doc1 = self.db.create_doc_from_json( +            '{"key": "value3", "key2": "value4"}') +        doc2 = self.db.create_doc_from_json( +            '{"key": "value2", "key2": "value3"}') +        doc3 = self.db.create_doc_from_json( +            '{"key": "value2", "key2": "value2"}') +        doc4 = self.db.create_doc_from_json( +            '{"key": "value1", "key2": "value1"}') +        self.db.create_index('test-idx', 'key', 'key2') +        self.assertEqual( +            [doc4, doc3, doc2, doc1], +            self.db.get_from_index('test-idx', 'v*', '*')) + +    def test_get_range_from_index_start_end(self): +        doc1 = self.db.create_doc_from_json('{"key": "value3"}') +        doc2 = self.db.create_doc_from_json('{"key": "value2"}') +        self.db.create_doc_from_json('{"key": "value4"}') +        self.db.create_doc_from_json('{"key": "value1"}') +        self.db.create_index('test-idx', 'key') +        self.assertEqual( +            [doc2, doc1], +            self.db.get_range_from_index('test-idx', 'value2', 'value3')) + +    def test_get_range_from_index_start(self): +        doc1 = self.db.create_doc_from_json('{"key": "value3"}') +        doc2 = self.db.create_doc_from_json('{"key": "value2"}') +        doc3 = self.db.create_doc_from_json('{"key": "value4"}') +        self.db.create_doc_from_json('{"key": "value1"}') +        self.db.create_index('test-idx', 'key') +        self.assertEqual( +            [doc2, doc1, doc3], +            self.db.get_range_from_index('test-idx', 'value2')) + +    def test_get_range_from_index_sees_conflicts(self): +        doc = self.db.create_doc_from_json(simple_doc) +        self.db.create_index('test-idx', 'key') +        alt_doc = self.make_document( +            doc.doc_id, 'alternate:1', '{"key": "valuedepalue"}') +        self.db._put_doc_if_newer( +            alt_doc, save_conflict=True, replica_uid='r', replica_gen=1, +            replica_trans_id='foo') +        docs = self.db.get_range_from_index('test-idx', 'a') +        self.assertTrue(docs[0].has_conflicts) + +    def test_get_range_from_index_end(self): +        self.db.create_doc_from_json('{"key": "value3"}') +        doc2 = self.db.create_doc_from_json('{"key": "value2"}') +        self.db.create_doc_from_json('{"key": "value4"}') +        doc4 = self.db.create_doc_from_json('{"key": "value1"}') +        self.db.create_index('test-idx', 'key') +        self.assertEqual( +            [doc4, doc2], +            self.db.get_range_from_index('test-idx', None, 'value2')) + +    def test_get_wildcard_range_from_index_start(self): +        doc1 = self.db.create_doc_from_json('{"key": "value4"}') +        doc2 = self.db.create_doc_from_json('{"key": "value23"}') +        doc3 = self.db.create_doc_from_json('{"key": "value2"}') +        doc4 = self.db.create_doc_from_json('{"key": "value22"}') +        self.db.create_doc_from_json('{"key": "value1"}') +        self.db.create_index('test-idx', 'key') +        self.assertEqual( +            [doc3, doc4, doc2, doc1], +            self.db.get_range_from_index('test-idx', 'value2*')) + +    def test_get_wildcard_range_from_index_end(self): +        self.db.create_doc_from_json('{"key": "value4"}') +        doc2 = self.db.create_doc_from_json('{"key": "value23"}') +        doc3 = self.db.create_doc_from_json('{"key": "value2"}') +        doc4 = self.db.create_doc_from_json('{"key": "value22"}') +        doc5 = self.db.create_doc_from_json('{"key": "value1"}') +        self.db.create_index('test-idx', 'key') +        self.assertEqual( +            [doc5, doc3, doc4, doc2], +            self.db.get_range_from_index('test-idx', None, 'value2*')) + +    def test_get_wildcard_range_from_index_start_end(self): +        self.db.create_doc_from_json('{"key": "a"}') +        self.db.create_doc_from_json('{"key": "boo3"}') +        doc3 = self.db.create_doc_from_json('{"key": "catalyst"}') +        doc4 = self.db.create_doc_from_json('{"key": "whaever"}') +        self.db.create_doc_from_json('{"key": "zerg"}') +        self.db.create_index('test-idx', 'key') +        self.assertEqual( +            [doc3, doc4], +            self.db.get_range_from_index('test-idx', 'cat*', 'zap*')) + +    def test_get_range_from_index_multi_column_start_end(self): +        self.db.create_doc_from_json('{"key": "value3", "key2": "value4"}') +        doc2 = self.db.create_doc_from_json( +            '{"key": "value2", "key2": "value3"}') +        doc3 = self.db.create_doc_from_json( +            '{"key": "value2", "key2": "value2"}') +        self.db.create_doc_from_json('{"key": "value1", "key2": "value1"}') +        self.db.create_index('test-idx', 'key', 'key2') +        self.assertEqual( +            [doc3, doc2], +            self.db.get_range_from_index( +                'test-idx', ('value2', 'value2'), ('value2', 'value3'))) + +    def test_get_range_from_index_multi_column_start(self): +        doc1 = self.db.create_doc_from_json( +            '{"key": "value3", "key2": "value4"}') +        doc2 = self.db.create_doc_from_json( +            '{"key": "value2", "key2": "value3"}') +        self.db.create_doc_from_json('{"key": "value2", "key2": "value2"}') +        self.db.create_doc_from_json('{"key": "value1", "key2": "value1"}') +        self.db.create_index('test-idx', 'key', 'key2') +        self.assertEqual( +            [doc2, doc1], +            self.db.get_range_from_index('test-idx', ('value2', 'value3'))) + +    def test_get_range_from_index_multi_column_end(self): +        self.db.create_doc_from_json('{"key": "value3", "key2": "value4"}') +        doc2 = self.db.create_doc_from_json( +            '{"key": "value2", "key2": "value3"}') +        doc3 = self.db.create_doc_from_json( +            '{"key": "value2", "key2": "value2"}') +        doc4 = self.db.create_doc_from_json( +            '{"key": "value1", "key2": "value1"}') +        self.db.create_index('test-idx', 'key', 'key2') +        self.assertEqual( +            [doc4, doc3, doc2], +            self.db.get_range_from_index( +                'test-idx', None, ('value2', 'value3'))) + +    def test_get_wildcard_range_from_index_multi_column_start(self): +        doc1 = self.db.create_doc_from_json( +            '{"key": "value3", "key2": "value4"}') +        doc2 = self.db.create_doc_from_json( +            '{"key": "value2", "key2": "value23"}') +        doc3 = self.db.create_doc_from_json( +            '{"key": "value2", "key2": "value2"}') +        self.db.create_doc_from_json('{"key": "value1", "key2": "value1"}') +        self.db.create_index('test-idx', 'key', 'key2') +        self.assertEqual( +            [doc3, doc2, doc1], +            self.db.get_range_from_index('test-idx', ('value2', 'value2*'))) + +    def test_get_wildcard_range_from_index_multi_column_end(self): +        self.db.create_doc_from_json('{"key": "value3", "key2": "value4"}') +        doc2 = self.db.create_doc_from_json( +            '{"key": "value2", "key2": "value23"}') +        doc3 = self.db.create_doc_from_json( +            '{"key": "value2", "key2": "value2"}') +        doc4 = self.db.create_doc_from_json( +            '{"key": "value1", "key2": "value1"}') +        self.db.create_index('test-idx', 'key', 'key2') +        self.assertEqual( +            [doc4, doc3, doc2], +            self.db.get_range_from_index( +                'test-idx', None, ('value2', 'value2*'))) + +    def test_get_glob_range_from_index_multi_column_start(self): +        doc1 = self.db.create_doc_from_json( +            '{"key": "value3", "key2": "value4"}') +        doc2 = self.db.create_doc_from_json( +            '{"key": "value2", "key2": "value23"}') +        self.db.create_doc_from_json('{"key": "value1", "key2": "value2"}') +        self.db.create_doc_from_json('{"key": "value1", "key2": "value1"}') +        self.db.create_index('test-idx', 'key', 'key2') +        self.assertEqual( +            [doc2, doc1], +            self.db.get_range_from_index('test-idx', ('value2', '*'))) + +    def test_get_glob_range_from_index_multi_column_end(self): +        self.db.create_doc_from_json('{"key": "value3", "key2": "value4"}') +        doc2 = self.db.create_doc_from_json( +            '{"key": "value2", "key2": "value23"}') +        doc3 = self.db.create_doc_from_json( +            '{"key": "value1", "key2": "value2"}') +        doc4 = self.db.create_doc_from_json( +            '{"key": "value1", "key2": "value1"}') +        self.db.create_index('test-idx', 'key', 'key2') +        self.assertEqual( +            [doc4, doc3, doc2], +            self.db.get_range_from_index('test-idx', None, ('value2', '*'))) + +    def test_get_range_from_index_illegal_wildcard_order(self): +        self.db.create_index('test-idx', 'k1', 'k2') +        self.assertRaises( +            errors.InvalidGlobbing, +            self.db.get_range_from_index, 'test-idx', ('*', 'v2')) + +    def test_get_range_from_index_illegal_glob_after_wildcard(self): +        self.db.create_index('test-idx', 'k1', 'k2') +        self.assertRaises( +            errors.InvalidGlobbing, +            self.db.get_range_from_index, 'test-idx', ('*', 'v*')) + +    def test_get_range_from_index_illegal_wildcard_order_end(self): +        self.db.create_index('test-idx', 'k1', 'k2') +        self.assertRaises( +            errors.InvalidGlobbing, +            self.db.get_range_from_index, 'test-idx', None, ('*', 'v2')) + +    def test_get_range_from_index_illegal_glob_after_wildcard_end(self): +        self.db.create_index('test-idx', 'k1', 'k2') +        self.assertRaises( +            errors.InvalidGlobbing, +            self.db.get_range_from_index, 'test-idx', None, ('*', 'v*')) + +    def test_get_from_index_fails_if_no_index(self): +        self.assertRaises( +            errors.IndexDoesNotExist, self.db.get_from_index, 'foo') + +    def test_get_index_keys_fails_if_no_index(self): +        self.assertRaises(errors.IndexDoesNotExist, +                          self.db.get_index_keys, +                          'foo') + +    def test_get_index_keys_works_if_no_docs(self): +        self.db.create_index('test-idx', 'key') +        self.assertEqual([], self.db.get_index_keys('test-idx')) + +    def test_put_updates_index(self): +        doc = self.db.create_doc_from_json(simple_doc) +        self.db.create_index('test-idx', 'key') +        new_content = '{"key": "altval"}' +        doc.set_json(new_content) +        self.db.put_doc(doc) +        self.assertEqual([], self.db.get_from_index('test-idx', 'value')) +        self.assertEqual([doc], self.db.get_from_index('test-idx', 'altval')) + +    def test_delete_updates_index(self): +        doc = self.db.create_doc_from_json(simple_doc) +        doc2 = self.db.create_doc_from_json(simple_doc) +        self.db.create_index('test-idx', 'key') +        self.assertEqual( +            sorted([doc, doc2]), +            sorted(self.db.get_from_index('test-idx', 'value'))) +        self.db.delete_doc(doc) +        self.assertEqual([doc2], self.db.get_from_index('test-idx', 'value')) + +    def test_get_from_index_illegal_number_of_entries(self): +        self.db.create_index('test-idx', 'k1', 'k2') +        self.assertRaises( +            errors.InvalidValueForIndex, self.db.get_from_index, 'test-idx') +        self.assertRaises( +            errors.InvalidValueForIndex, +            self.db.get_from_index, 'test-idx', 'v1') +        self.assertRaises( +            errors.InvalidValueForIndex, +            self.db.get_from_index, 'test-idx', 'v1', 'v2', 'v3') + +    def test_get_from_index_illegal_wildcard_order(self): +        self.db.create_index('test-idx', 'k1', 'k2') +        self.assertRaises( +            errors.InvalidGlobbing, +            self.db.get_from_index, 'test-idx', '*', 'v2') + +    def test_get_from_index_illegal_glob_after_wildcard(self): +        self.db.create_index('test-idx', 'k1', 'k2') +        self.assertRaises( +            errors.InvalidGlobbing, +            self.db.get_from_index, 'test-idx', '*', 'v*') + +    def test_get_all_from_index(self): +        self.db.create_index('test-idx', 'key') +        doc1 = self.db.create_doc_from_json(simple_doc) +        doc2 = self.db.create_doc_from_json(nested_doc) +        # This one should not be in the index +        self.db.create_doc_from_json('{"no": "key"}') +        diff_value_doc = '{"key": "diff value"}' +        doc4 = self.db.create_doc_from_json(diff_value_doc) +        # This is essentially a 'prefix' match, but we match every entry. +        self.assertEqual( +            sorted([doc1, doc2, doc4]), +            sorted(self.db.get_from_index('test-idx', '*'))) + +    def test_get_all_from_index_ordered(self): +        self.db.create_index('test-idx', 'key') +        doc1 = self.db.create_doc_from_json('{"key": "value x"}') +        doc2 = self.db.create_doc_from_json('{"key": "value b"}') +        doc3 = self.db.create_doc_from_json('{"key": "value a"}') +        doc4 = self.db.create_doc_from_json('{"key": "value m"}') +        # This is essentially a 'prefix' match, but we match every entry. +        self.assertEqual( +            [doc3, doc2, doc4, doc1], self.db.get_from_index('test-idx', '*')) + +    def test_put_updates_when_adding_key(self): +        doc = self.db.create_doc_from_json("{}") +        self.db.create_index('test-idx', 'key') +        self.assertEqual([], self.db.get_from_index('test-idx', '*')) +        doc.set_json(simple_doc) +        self.db.put_doc(doc) +        self.assertEqual([doc], self.db.get_from_index('test-idx', '*')) + +    def test_get_from_index_empty_string(self): +        self.db.create_index('test-idx', 'key') +        doc1 = self.db.create_doc_from_json(simple_doc) +        content2 = '{"key": ""}' +        doc2 = self.db.create_doc_from_json(content2) +        self.assertEqual([doc2], self.db.get_from_index('test-idx', '')) +        # Empty string matches the wildcard. +        self.assertEqual( +            sorted([doc1, doc2]), +            sorted(self.db.get_from_index('test-idx', '*'))) + +    def test_get_from_index_not_null(self): +        self.db.create_index('test-idx', 'key') +        doc1 = self.db.create_doc_from_json(simple_doc) +        self.db.create_doc_from_json('{"key": null}') +        self.assertEqual([doc1], self.db.get_from_index('test-idx', '*')) + +    def test_get_partial_from_index(self): +        content1 = '{"k1": "v1", "k2": "v2"}' +        content2 = '{"k1": "v1", "k2": "x2"}' +        content3 = '{"k1": "v1", "k2": "y2"}' +        # doc4 has a different k1 value, so it doesn't match the prefix. +        content4 = '{"k1": "NN", "k2": "v2"}' +        doc1 = self.db.create_doc_from_json(content1) +        doc2 = self.db.create_doc_from_json(content2) +        doc3 = self.db.create_doc_from_json(content3) +        self.db.create_doc_from_json(content4) +        self.db.create_index('test-idx', 'k1', 'k2') +        self.assertEqual( +            sorted([doc1, doc2, doc3]), +            sorted(self.db.get_from_index('test-idx', "v1", "*"))) + +    def test_get_glob_match(self): +        # Note: the exact glob syntax is probably subject to change +        content1 = '{"k1": "v1", "k2": "v1"}' +        content2 = '{"k1": "v1", "k2": "v2"}' +        content3 = '{"k1": "v1", "k2": "v3"}' +        # doc4 has a different k2 prefix value, so it doesn't match +        content4 = '{"k1": "v1", "k2": "ZZ"}' +        self.db.create_index('test-idx', 'k1', 'k2') +        doc1 = self.db.create_doc_from_json(content1) +        doc2 = self.db.create_doc_from_json(content2) +        doc3 = self.db.create_doc_from_json(content3) +        self.db.create_doc_from_json(content4) +        self.assertEqual( +            sorted([doc1, doc2, doc3]), +            sorted(self.db.get_from_index('test-idx', "v1", "v*"))) + +    def test_nested_index(self): +        doc = self.db.create_doc_from_json(nested_doc) +        self.db.create_index('test-idx', 'sub.doc') +        self.assertEqual( +            [doc], self.db.get_from_index('test-idx', 'underneath')) +        doc2 = self.db.create_doc_from_json(nested_doc) +        self.assertEqual( +            sorted([doc, doc2]), +            sorted(self.db.get_from_index('test-idx', 'underneath'))) + +    def test_nested_nonexistent(self): +        self.db.create_doc_from_json(nested_doc) +        # sub exists, but sub.foo does not: +        self.db.create_index('test-idx', 'sub.foo') +        self.assertEqual([], self.db.get_from_index('test-idx', '*')) + +    def test_nested_nonexistent2(self): +        self.db.create_doc_from_json(nested_doc) +        self.db.create_index('test-idx', 'sub.foo.bar.baz.qux.fnord') +        self.assertEqual([], self.db.get_from_index('test-idx', '*')) + +    def test_nested_traverses_lists(self): +        # subpath finds dicts in list +        doc = self.db.create_doc_from_json( +            '{"foo": [{"zap": "bar"}, {"zap": "baz"}]}') +        # subpath only finds dicts in list +        self.db.create_doc_from_json('{"foo": ["zap", "baz"]}') +        self.db.create_index('test-idx', 'foo.zap') +        self.assertEqual([doc], self.db.get_from_index('test-idx', 'bar')) +        self.assertEqual([doc], self.db.get_from_index('test-idx', 'baz')) + +    def test_nested_list_traversal(self): +        # subpath finds dicts in list +        doc = self.db.create_doc_from_json( +            '{"foo": [{"zap": [{"qux": "fnord"}, {"qux": "zombo"}]},' +            '{"zap": "baz"}]}') +        # subpath only finds dicts in list +        self.db.create_index('test-idx', 'foo.zap.qux') +        self.assertEqual([doc], self.db.get_from_index('test-idx', 'fnord')) +        self.assertEqual([doc], self.db.get_from_index('test-idx', 'zombo')) + +    def test_index_list1(self): +        self.db.create_index("index", "name") +        content = '{"name": ["foo", "bar"]}' +        doc = self.db.create_doc_from_json(content) +        rows = self.db.get_from_index("index", "bar") +        self.assertEqual([doc], rows) + +    def test_index_list2(self): +        self.db.create_index("index", "name") +        content = '{"name": ["foo", "bar"]}' +        doc = self.db.create_doc_from_json(content) +        rows = self.db.get_from_index("index", "foo") +        self.assertEqual([doc], rows) + +    def test_get_from_index_case_sensitive(self): +        self.db.create_index('test-idx', 'key') +        doc1 = self.db.create_doc_from_json(simple_doc) +        self.assertEqual([], self.db.get_from_index('test-idx', 'V*')) +        self.assertEqual([doc1], self.db.get_from_index('test-idx', 'v*')) + +    def test_get_from_index_illegal_glob_before_value(self): +        self.db.create_index('test-idx', 'k1', 'k2') +        self.assertRaises( +            errors.InvalidGlobbing, +            self.db.get_from_index, 'test-idx', 'v*', 'v2') + +    def test_get_from_index_illegal_glob_after_glob(self): +        self.db.create_index('test-idx', 'k1', 'k2') +        self.assertRaises( +            errors.InvalidGlobbing, +            self.db.get_from_index, 'test-idx', 'v*', 'v*') + +    def test_get_from_index_with_sql_wildcards(self): +        self.db.create_index('test-idx', 'key') +        content1 = '{"key": "va%lue"}' +        content2 = '{"key": "value"}' +        content3 = '{"key": "va_lue"}' +        doc1 = self.db.create_doc_from_json(content1) +        self.db.create_doc_from_json(content2) +        doc3 = self.db.create_doc_from_json(content3) +        # The '%' in the search should be treated literally, not as a sql +        # globbing character. +        self.assertEqual([doc1], self.db.get_from_index('test-idx', 'va%*')) +        # Same for '_' +        self.assertEqual([doc3], self.db.get_from_index('test-idx', 'va_*')) + +    def test_get_from_index_with_lower(self): +        self.db.create_index("index", "lower(name)") +        content = '{"name": "Foo"}' +        doc = self.db.create_doc_from_json(content) +        rows = self.db.get_from_index("index", "foo") +        self.assertEqual([doc], rows) + +    def test_get_from_index_with_lower_matches_same_case(self): +        self.db.create_index("index", "lower(name)") +        content = '{"name": "foo"}' +        doc = self.db.create_doc_from_json(content) +        rows = self.db.get_from_index("index", "foo") +        self.assertEqual([doc], rows) + +    def test_index_lower_doesnt_match_different_case(self): +        self.db.create_index("index", "lower(name)") +        content = '{"name": "Foo"}' +        self.db.create_doc_from_json(content) +        rows = self.db.get_from_index("index", "Foo") +        self.assertEqual([], rows) + +    def test_index_lower_doesnt_match_other_index(self): +        self.db.create_index("index", "lower(name)") +        self.db.create_index("other_index", "name") +        content = '{"name": "Foo"}' +        self.db.create_doc_from_json(content) +        rows = self.db.get_from_index("index", "Foo") +        self.assertEqual(0, len(rows)) + +    def test_index_split_words_match_first(self): +        self.db.create_index("index", "split_words(name)") +        content = '{"name": "foo bar"}' +        doc = self.db.create_doc_from_json(content) +        rows = self.db.get_from_index("index", "foo") +        self.assertEqual([doc], rows) + +    def test_index_split_words_match_second(self): +        self.db.create_index("index", "split_words(name)") +        content = '{"name": "foo bar"}' +        doc = self.db.create_doc_from_json(content) +        rows = self.db.get_from_index("index", "bar") +        self.assertEqual([doc], rows) + +    def test_index_split_words_match_both(self): +        self.db.create_index("index", "split_words(name)") +        content = '{"name": "foo foo"}' +        doc = self.db.create_doc_from_json(content) +        rows = self.db.get_from_index("index", "foo") +        self.assertEqual([doc], rows) + +    def test_index_split_words_double_space(self): +        self.db.create_index("index", "split_words(name)") +        content = '{"name": "foo  bar"}' +        doc = self.db.create_doc_from_json(content) +        rows = self.db.get_from_index("index", "bar") +        self.assertEqual([doc], rows) + +    def test_index_split_words_leading_space(self): +        self.db.create_index("index", "split_words(name)") +        content = '{"name": " foo bar"}' +        doc = self.db.create_doc_from_json(content) +        rows = self.db.get_from_index("index", "foo") +        self.assertEqual([doc], rows) + +    def test_index_split_words_trailing_space(self): +        self.db.create_index("index", "split_words(name)") +        content = '{"name": "foo bar "}' +        doc = self.db.create_doc_from_json(content) +        rows = self.db.get_from_index("index", "bar") +        self.assertEqual([doc], rows) + +    def test_get_from_index_with_number(self): +        self.db.create_index("index", "number(foo, 5)") +        content = '{"foo": 12}' +        doc = self.db.create_doc_from_json(content) +        rows = self.db.get_from_index("index", "00012") +        self.assertEqual([doc], rows) + +    def test_get_from_index_with_number_bigger_than_padding(self): +        self.db.create_index("index", "number(foo, 5)") +        content = '{"foo": 123456}' +        doc = self.db.create_doc_from_json(content) +        rows = self.db.get_from_index("index", "123456") +        self.assertEqual([doc], rows) + +    def test_number_mapping_ignores_non_numbers(self): +        self.db.create_index("index", "number(foo, 5)") +        content = '{"foo": 56}' +        doc1 = self.db.create_doc_from_json(content) +        content = '{"foo": "this is not a maigret painting"}' +        self.db.create_doc_from_json(content) +        rows = self.db.get_from_index("index", "*") +        self.assertEqual([doc1], rows) + +    def test_get_from_index_with_bool(self): +        self.db.create_index("index", "bool(foo)") +        content = '{"foo": true}' +        doc = self.db.create_doc_from_json(content) +        rows = self.db.get_from_index("index", "1") +        self.assertEqual([doc], rows) + +    def test_get_from_index_with_bool_false(self): +        self.db.create_index("index", "bool(foo)") +        content = '{"foo": false}' +        doc = self.db.create_doc_from_json(content) +        rows = self.db.get_from_index("index", "0") +        self.assertEqual([doc], rows) + +    def test_get_from_index_with_non_bool(self): +        self.db.create_index("index", "bool(foo)") +        content = '{"foo": 42}' +        self.db.create_doc_from_json(content) +        rows = self.db.get_from_index("index", "*") +        self.assertEqual([], rows) + +    def test_get_from_index_with_combine(self): +        self.db.create_index("index", "combine(foo, bar)") +        content = '{"foo": "value1", "bar": "value2"}' +        doc = self.db.create_doc_from_json(content) +        rows = self.db.get_from_index("index", "value1") +        self.assertEqual([doc], rows) +        rows = self.db.get_from_index("index", "value2") +        self.assertEqual([doc], rows) + +    def test_get_complex_combine(self): +        self.db.create_index( +            "index", "combine(number(foo, 5), lower(bar), split_words(baz))") +        content = '{"foo": 12, "bar": "ALLCAPS", "baz": "qux nox"}' +        doc = self.db.create_doc_from_json(content) +        content = '{"foo": "not a number", "bar": "something"}' +        doc2 = self.db.create_doc_from_json(content) +        rows = self.db.get_from_index("index", "00012") +        self.assertEqual([doc], rows) +        rows = self.db.get_from_index("index", "allcaps") +        self.assertEqual([doc], rows) +        rows = self.db.get_from_index("index", "nox") +        self.assertEqual([doc], rows) +        rows = self.db.get_from_index("index", "something") +        self.assertEqual([doc2], rows) + +    def test_get_index_keys_from_index(self): +        self.db.create_index('test-idx', 'key') +        content1 = '{"key": "value1"}' +        content2 = '{"key": "value2"}' +        content3 = '{"key": "value2"}' +        self.db.create_doc_from_json(content1) +        self.db.create_doc_from_json(content2) +        self.db.create_doc_from_json(content3) +        self.assertEqual( +            [('value1',), ('value2',)], +            sorted(self.db.get_index_keys('test-idx'))) + +    def test_get_index_keys_from_multicolumn_index(self): +        self.db.create_index('test-idx', 'key1', 'key2') +        content1 = '{"key1": "value1", "key2": "val2-1"}' +        content2 = '{"key1": "value2", "key2": "val2-2"}' +        content3 = '{"key1": "value2", "key2": "val2-2"}' +        content4 = '{"key1": "value2", "key2": "val3"}' +        self.db.create_doc_from_json(content1) +        self.db.create_doc_from_json(content2) +        self.db.create_doc_from_json(content3) +        self.db.create_doc_from_json(content4) +        self.assertEqual([ +            ('value1', 'val2-1'), +            ('value2', 'val2-2'), +            ('value2', 'val3')], +            sorted(self.db.get_index_keys('test-idx'))) + +    def test_empty_expr(self): +        self.assertParseError('') + +    def test_nested_unknown_operation(self): +        self.assertParseError('unknown_operation(field1)') + +    def test_parse_missing_close_paren(self): +        self.assertParseError("lower(a") + +    def test_parse_trailing_close_paren(self): +        self.assertParseError("lower(ab))") + +    def test_parse_trailing_chars(self): +        self.assertParseError("lower(ab)adsf") + +    def test_parse_empty_op(self): +        self.assertParseError("(ab)") + +    def test_parse_top_level_commas(self): +        self.assertParseError("a, b") + +    def test_invalid_field_name(self): +        self.assertParseError("a.") + +    def test_invalid_inner_field_name(self): +        self.assertParseError("lower(a.)") + +    def test_gobbledigook(self): +        self.assertParseError("(@#@cc   @#!*DFJSXV(()jccd") + +    def test_leading_space(self): +        self.assertIndexCreatable("  lower(a)") + +    def test_trailing_space(self): +        self.assertIndexCreatable("lower(a)  ") + +    def test_spaces_before_open_paren(self): +        self.assertIndexCreatable("lower  (a)") + +    def test_spaces_after_open_paren(self): +        self.assertIndexCreatable("lower(  a)") + +    def test_spaces_before_close_paren(self): +        self.assertIndexCreatable("lower(a  )") + +    def test_spaces_before_comma(self): +        self.assertIndexCreatable("combine(a  , b  , c)") + +    def test_spaces_after_comma(self): +        self.assertIndexCreatable("combine(a,  b,  c)") + +    def test_all_together_now(self): +        self.assertParseError('    (a) ') + +    def test_all_together_now2(self): +        self.assertParseError('combine(lower(x)x,foo)') + + +class PythonBackendTests(tests.DatabaseBaseTests): + +    def setUp(self): +        super(PythonBackendTests, self).setUp() +        self.simple_doc = json.loads(simple_doc) + +    def test_create_doc_with_factory(self): +        self.db.set_document_factory(TestAlternativeDocument) +        doc = self.db.create_doc(self.simple_doc, doc_id='my_doc_id') +        self.assertTrue(isinstance(doc, TestAlternativeDocument)) + +    def test_get_doc_after_put_with_factory(self): +        doc = self.db.create_doc(self.simple_doc, doc_id='my_doc_id') +        self.db.set_document_factory(TestAlternativeDocument) +        result = self.db.get_doc('my_doc_id') +        self.assertTrue(isinstance(result, TestAlternativeDocument)) +        self.assertEqual(doc.doc_id, result.doc_id) +        self.assertEqual(doc.rev, result.rev) +        self.assertEqual(doc.get_json(), result.get_json()) +        self.assertEqual(False, result.has_conflicts) + +    def test_get_doc_nonexisting_with_factory(self): +        self.db.set_document_factory(TestAlternativeDocument) +        self.assertIs(None, self.db.get_doc('non-existing')) + +    def test_get_all_docs_with_factory(self): +        self.db.set_document_factory(TestAlternativeDocument) +        self.db.create_doc(self.simple_doc) +        self.assertTrue(isinstance( +            list(self.db.get_all_docs()[1])[0], TestAlternativeDocument)) + +    def test_get_docs_conflicted_with_factory(self): +        self.db.set_document_factory(TestAlternativeDocument) +        doc1 = self.db.create_doc(self.simple_doc) +        doc2 = self.make_document(doc1.doc_id, 'alternate:1', nested_doc) +        self.db._put_doc_if_newer( +            doc2, save_conflict=True, replica_uid='r', replica_gen=1, +            replica_trans_id='foo') +        self.assertTrue( +            isinstance( +                list(self.db.get_docs([doc1.doc_id]))[0], +                TestAlternativeDocument)) + +    def test_get_from_index_with_factory(self): +        self.db.set_document_factory(TestAlternativeDocument) +        self.db.create_doc(self.simple_doc) +        self.db.create_index('test-idx', 'key') +        self.assertTrue( +            isinstance( +                self.db.get_from_index('test-idx', 'value')[0], +                TestAlternativeDocument)) + +    def test_sync_exchange_updates_indexes(self): +        doc = self.db.create_doc(self.simple_doc) +        self.db.create_index('test-idx', 'key') +        new_content = '{"key": "altval"}' +        other_rev = 'test:1|z:2' +        st = self.db.get_sync_target() + +        def ignore(doc_id, doc_rev, doc): +            pass + +        doc_other = self.make_document(doc.doc_id, other_rev, new_content) +        docs_by_gen = [(doc_other, 10, 'T-sid')] +        st.sync_exchange( +            docs_by_gen, 'other-replica', last_known_generation=0, +            last_known_trans_id=None, return_doc_cb=ignore) +        self.assertGetDoc(self.db, doc.doc_id, other_rev, new_content, False) +        self.assertEqual( +            [doc_other], self.db.get_from_index('test-idx', 'altval')) +        self.assertEqual([], self.db.get_from_index('test-idx', 'value')) + + +# Use a custom loader to apply the scenarios at load time. +load_tests = tests.load_with_scenarios diff --git a/common/src/leap/soledad/common/tests/u1db_tests/test_document.py b/common/src/leap/soledad/common/tests/u1db_tests/test_document.py new file mode 100644 index 00000000..8b30ed51 --- /dev/null +++ b/common/src/leap/soledad/common/tests/u1db_tests/test_document.py @@ -0,0 +1,150 @@ +# Copyright 2011 Canonical Ltd. +# +# This file is part of u1db. +# +# u1db is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License version 3 +# as published by the Free Software Foundation. +# +# u1db is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with u1db.  If not, see <http://www.gnu.org/licenses/>. + + +from u1db import errors + +from leap.soledad.common.tests import u1db_tests as tests + + +class TestDocument(tests.TestCase): + +    scenarios = ([( +        'py', {'make_document_for_test': tests.make_document_for_test})])  # + +        #tests.C_DATABASE_SCENARIOS) + +    def test_create_doc(self): +        doc = self.make_document('doc-id', 'uid:1', tests.simple_doc) +        self.assertEqual('doc-id', doc.doc_id) +        self.assertEqual('uid:1', doc.rev) +        self.assertEqual(tests.simple_doc, doc.get_json()) +        self.assertFalse(doc.has_conflicts) + +    def test__repr__(self): +        doc = self.make_document('doc-id', 'uid:1', tests.simple_doc) +        self.assertEqual( +            '%s(doc-id, uid:1, \'{"key": "value"}\')' +            % (doc.__class__.__name__,), +            repr(doc)) + +    def test__repr__conflicted(self): +        doc = self.make_document('doc-id', 'uid:1', tests.simple_doc, +                                 has_conflicts=True) +        self.assertEqual( +            '%s(doc-id, uid:1, conflicted, \'{"key": "value"}\')' +            % (doc.__class__.__name__,), +            repr(doc)) + +    def test__lt__(self): +        doc_a = self.make_document('a', 'b', '{}') +        doc_b = self.make_document('b', 'b', '{}') +        self.assertTrue(doc_a < doc_b) +        self.assertTrue(doc_b > doc_a) +        doc_aa = self.make_document('a', 'a', '{}') +        self.assertTrue(doc_aa < doc_a) + +    def test__eq__(self): +        doc_a = self.make_document('a', 'b', '{}') +        doc_b = self.make_document('a', 'b', '{}') +        self.assertTrue(doc_a == doc_b) +        doc_b = self.make_document('a', 'b', '{}', has_conflicts=True) +        self.assertFalse(doc_a == doc_b) + +    def test_non_json_dict(self): +        self.assertRaises( +            errors.InvalidJSON, self.make_document, 'id', 'uid:1', +            '"not a json dictionary"') + +    def test_non_json(self): +        self.assertRaises( +            errors.InvalidJSON, self.make_document, 'id', 'uid:1', +            'not a json dictionary') + +    def test_get_size(self): +        doc_a = self.make_document('a', 'b', '{"some": "content"}') +        self.assertEqual( +            len('a' + 'b' + '{"some": "content"}'), doc_a.get_size()) + +    def test_get_size_empty_document(self): +        doc_a = self.make_document('a', 'b', None) +        self.assertEqual(len('a' + 'b'), doc_a.get_size()) + + +class TestPyDocument(tests.TestCase): + +    scenarios = ([( +        'py', {'make_document_for_test': tests.make_document_for_test})]) + +    def test_get_content(self): +        doc = self.make_document('id', 'rev', '{"content":""}') +        self.assertEqual({"content": ""}, doc.content) +        doc.set_json('{"content": "new"}') +        self.assertEqual({"content": "new"}, doc.content) + +    def test_set_content(self): +        doc = self.make_document('id', 'rev', '{"content":""}') +        doc.content = {"content": "new"} +        self.assertEqual('{"content": "new"}', doc.get_json()) + +    def test_set_bad_content(self): +        doc = self.make_document('id', 'rev', '{"content":""}') +        self.assertRaises( +            errors.InvalidContent, setattr, doc, 'content', +            '{"content": "new"}') + +    def test_is_tombstone(self): +        doc_a = self.make_document('a', 'b', '{}') +        self.assertFalse(doc_a.is_tombstone()) +        doc_a.set_json(None) +        self.assertTrue(doc_a.is_tombstone()) + +    def test_make_tombstone(self): +        doc_a = self.make_document('a', 'b', '{}') +        self.assertFalse(doc_a.is_tombstone()) +        doc_a.make_tombstone() +        self.assertTrue(doc_a.is_tombstone()) + +    def test_same_content_as(self): +        doc_a = self.make_document('a', 'b', '{}') +        doc_b = self.make_document('d', 'e', '{}') +        self.assertTrue(doc_a.same_content_as(doc_b)) +        doc_b = self.make_document('p', 'q', '{}', has_conflicts=True) +        self.assertTrue(doc_a.same_content_as(doc_b)) +        doc_b.content['key'] = 'value' +        self.assertFalse(doc_a.same_content_as(doc_b)) + +    def test_same_content_as_json_order(self): +        doc_a = self.make_document( +            'a', 'b', '{"key1": "val1", "key2": "val2"}') +        doc_b = self.make_document( +            'c', 'd', '{"key2": "val2", "key1": "val1"}') +        self.assertTrue(doc_a.same_content_as(doc_b)) + +    def test_set_json(self): +        doc = self.make_document('id', 'rev', '{"content":""}') +        doc.set_json('{"content": "new"}') +        self.assertEqual('{"content": "new"}', doc.get_json()) + +    def test_set_json_non_dict(self): +        doc = self.make_document('id', 'rev', '{"content":""}') +        self.assertRaises(errors.InvalidJSON, doc.set_json, '"is not a dict"') + +    def test_set_json_error(self): +        doc = self.make_document('id', 'rev', '{"content":""}') +        self.assertRaises(errors.InvalidJSON, doc.set_json, 'is not json') + + +load_tests = tests.load_with_scenarios diff --git a/common/src/leap/soledad/common/tests/u1db_tests/test_http_app.py b/common/src/leap/soledad/common/tests/u1db_tests/test_http_app.py new file mode 100644 index 00000000..789006ba --- /dev/null +++ b/common/src/leap/soledad/common/tests/u1db_tests/test_http_app.py @@ -0,0 +1,1135 @@ +# Copyright 2011-2012 Canonical Ltd. +# +# This file is part of u1db. +# +# u1db is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License version 3 +# as published by the Free Software Foundation. +# +# u1db is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with u1db.  If not, see <http://www.gnu.org/licenses/>. + +"""Test the WSGI app.""" + +import paste.fixture +import sys +try: +    import simplejson as json +except ImportError: +    import json  # noqa +import StringIO + +from u1db import ( +    __version__ as _u1db_version, +    errors, +    sync, +) + +from leap.soledad.common.tests import u1db_tests as tests + +from u1db.remote import ( +    http_app, +    http_errors, +) + + +class TestFencedReader(tests.TestCase): + +    def test_init(self): +        reader = http_app._FencedReader(StringIO.StringIO(""), 25, 100) +        self.assertEqual(25, reader.remaining) + +    def test_read_chunk(self): +        inp = StringIO.StringIO("abcdef") +        reader = http_app._FencedReader(inp, 5, 10) +        data = reader.read_chunk(2) +        self.assertEqual("ab", data) +        self.assertEqual(2, inp.tell()) +        self.assertEqual(3, reader.remaining) + +    def test_read_chunk_remaining(self): +        inp = StringIO.StringIO("abcdef") +        reader = http_app._FencedReader(inp, 4, 10) +        data = reader.read_chunk(9999) +        self.assertEqual("abcd", data) +        self.assertEqual(4, inp.tell()) +        self.assertEqual(0, reader.remaining) + +    def test_read_chunk_nothing_left(self): +        inp = StringIO.StringIO("abc") +        reader = http_app._FencedReader(inp, 2, 10) +        reader.read_chunk(2) +        self.assertEqual(2, inp.tell()) +        self.assertEqual(0, reader.remaining) +        data = reader.read_chunk(2) +        self.assertEqual("", data) +        self.assertEqual(2, inp.tell()) +        self.assertEqual(0, reader.remaining) + +    def test_read_chunk_kept(self): +        inp = StringIO.StringIO("abcde") +        reader = http_app._FencedReader(inp, 4, 10) +        reader._kept = "xyz" +        data = reader.read_chunk(2)  # atmost ignored +        self.assertEqual("xyz", data) +        self.assertEqual(0, inp.tell()) +        self.assertEqual(4, reader.remaining) +        self.assertIsNone(reader._kept) + +    def test_getline(self): +        inp = StringIO.StringIO("abc\r\nde") +        reader = http_app._FencedReader(inp, 6, 10) +        reader.MAXCHUNK = 6 +        line = reader.getline() +        self.assertEqual("abc\r\n", line) +        self.assertEqual("d", reader._kept) + +    def test_getline_exact(self): +        inp = StringIO.StringIO("abcd\r\nef") +        reader = http_app._FencedReader(inp, 6, 10) +        reader.MAXCHUNK = 6 +        line = reader.getline() +        self.assertEqual("abcd\r\n", line) +        self.assertIs(None, reader._kept) + +    def test_getline_no_newline(self): +        inp = StringIO.StringIO("abcd") +        reader = http_app._FencedReader(inp, 4, 10) +        reader.MAXCHUNK = 6 +        line = reader.getline() +        self.assertEqual("abcd", line) + +    def test_getline_many_chunks(self): +        inp = StringIO.StringIO("abcde\r\nf") +        reader = http_app._FencedReader(inp, 8, 10) +        reader.MAXCHUNK = 4 +        line = reader.getline() +        self.assertEqual("abcde\r\n", line) +        self.assertEqual("f", reader._kept) +        line = reader.getline() +        self.assertEqual("f", line) + +    def test_getline_empty(self): +        inp = StringIO.StringIO("") +        reader = http_app._FencedReader(inp, 0, 10) +        reader.MAXCHUNK = 4 +        line = reader.getline() +        self.assertEqual("", line) +        line = reader.getline() +        self.assertEqual("", line) + +    def test_getline_just_newline(self): +        inp = StringIO.StringIO("\r\n") +        reader = http_app._FencedReader(inp, 2, 10) +        reader.MAXCHUNK = 4 +        line = reader.getline() +        self.assertEqual("\r\n", line) +        line = reader.getline() +        self.assertEqual("", line) + +    def test_getline_too_large(self): +        inp = StringIO.StringIO("x" * 50) +        reader = http_app._FencedReader(inp, 50, 25) +        reader.MAXCHUNK = 4 +        self.assertRaises(http_app.BadRequest, reader.getline) + +    def test_getline_too_large_complete(self): +        inp = StringIO.StringIO("x" * 25 + "\r\n") +        reader = http_app._FencedReader(inp, 50, 25) +        reader.MAXCHUNK = 4 +        self.assertRaises(http_app.BadRequest, reader.getline) + + +class TestHTTPMethodDecorator(tests.TestCase): + +    def test_args(self): +        @http_app.http_method() +        def f(self, a, b): +            return self, a, b +        res = f("self", {"a": "x", "b": "y"}, None) +        self.assertEqual(("self", "x", "y"), res) + +    def test_args_missing(self): +        @http_app.http_method() +        def f(self, a, b): +            return a, b +        self.assertRaises(http_app.BadRequest, f, "self", {"a": "x"}, None) + +    def test_args_unexpected(self): +        @http_app.http_method() +        def f(self, a): +            return a +        self.assertRaises(http_app.BadRequest, f, "self", +                                                  {"a": "x", "c": "z"}, None) + +    def test_args_default(self): +        @http_app.http_method() +        def f(self, a, b="z"): +            return a, b +        res = f("self", {"a": "x"}, None) +        self.assertEqual(("x", "z"), res) + +    def test_args_conversion(self): +        @http_app.http_method(b=int) +        def f(self, a, b): +            return self, a, b +        res = f("self", {"a": "x", "b": "2"}, None) +        self.assertEqual(("self", "x", 2), res) + +        self.assertRaises(http_app.BadRequest, f, "self", +                                                  {"a": "x", "b": "foo"}, None) + +    def test_args_conversion_with_default(self): +        @http_app.http_method(b=str) +        def f(self, a, b=None): +            return self, a, b +        res = f("self", {"a": "x"}, None) +        self.assertEqual(("self", "x", None), res) + +    def test_args_content(self): +        @http_app.http_method() +        def f(self, a, content): +            return a, content +        res = f(self, {"a": "x"}, "CONTENT") +        self.assertEqual(("x", "CONTENT"), res) + +    def test_args_content_as_args(self): +        @http_app.http_method(b=int, content_as_args=True) +        def f(self, a, b): +            return self, a, b +        res = f("self", {"a": "x"}, '{"b": "2"}') +        self.assertEqual(("self", "x", 2), res) + +        self.assertRaises(http_app.BadRequest, f, "self", {}, 'not-json') + +    def test_args_content_no_query(self): +        @http_app.http_method(no_query=True, +                              content_as_args=True) +        def f(self, a='a', b='b'): +            return a, b +        res = f("self", {}, '{"b": "y"}') +        self.assertEqual(('a', 'y'), res) + +        self.assertRaises(http_app.BadRequest, f, "self", {'a': 'x'}, +                          '{"b": "y"}') + + +class TestResource(object): + +    @http_app.http_method() +    def get(self, a, b): +        self.args = dict(a=a, b=b) +        return 'Get' + +    @http_app.http_method() +    def put(self, a, content): +        self.args = dict(a=a) +        self.content = content +        return 'Put' + +    @http_app.http_method(content_as_args=True) +    def put_args(self, a, b): +        self.args = dict(a=a, b=b) +        self.order = ['a'] +        self.entries = [] + +    @http_app.http_method() +    def put_stream_entry(self, content): +        self.entries.append(content) +        self.order.append('s') + +    def put_end(self): +        self.order.append('e') +        return "Put/end" + + +class parameters: +    max_request_size = 200000 +    max_entry_size = 100000 + + +class TestHTTPInvocationByMethodWithBody(tests.TestCase): + +    def test_get(self): +        resource = TestResource() +        environ = {'QUERY_STRING': 'a=1&b=2', 'REQUEST_METHOD': 'GET'} +        invoke = http_app.HTTPInvocationByMethodWithBody(resource, environ, +                                                         parameters) +        res = invoke() +        self.assertEqual('Get', res) +        self.assertEqual({'a': '1', 'b': '2'}, resource.args) + +    def test_put_json(self): +        resource = TestResource() +        body = '{"body": true}' +        environ = {'QUERY_STRING': 'a=1', 'REQUEST_METHOD': 'PUT', +                   'wsgi.input': StringIO.StringIO(body), +                   'CONTENT_LENGTH': str(len(body)), +                   'CONTENT_TYPE': 'application/json'} +        invoke = http_app.HTTPInvocationByMethodWithBody(resource, environ, +                                                         parameters) +        res = invoke() +        self.assertEqual('Put', res) +        self.assertEqual({'a': '1'}, resource.args) +        self.assertEqual('{"body": true}', resource.content) + +    def test_put_sync_stream(self): +        resource = TestResource() +        body = ( +            '[\r\n' +            '{"b": 2},\r\n'        # args +            '{"entry": "x"},\r\n'  # stream entry +            '{"entry": "y"}\r\n'   # stream entry +            ']' +        ) +        environ = {'QUERY_STRING': 'a=1', 'REQUEST_METHOD': 'PUT', +                   'wsgi.input': StringIO.StringIO(body), +                   'CONTENT_LENGTH': str(len(body)), +                   'CONTENT_TYPE': 'application/x-u1db-sync-stream'} +        invoke = http_app.HTTPInvocationByMethodWithBody(resource, environ, +                                                         parameters) +        res = invoke() +        self.assertEqual('Put/end', res) +        self.assertEqual({'a': '1', 'b': 2}, resource.args) +        self.assertEqual( +            ['{"entry": "x"}', '{"entry": "y"}'], resource.entries) +        self.assertEqual(['a', 's', 's', 'e'], resource.order) + +    def _put_sync_stream(self, body): +        resource = TestResource() +        environ = {'QUERY_STRING': 'a=1&b=2', 'REQUEST_METHOD': 'PUT', +                   'wsgi.input': StringIO.StringIO(body), +                   'CONTENT_LENGTH': str(len(body)), +                   'CONTENT_TYPE': 'application/x-u1db-sync-stream'} +        invoke = http_app.HTTPInvocationByMethodWithBody(resource, environ, +                                                         parameters) +        invoke() + +    def test_put_sync_stream_wrong_start(self): +        self.assertRaises(http_app.BadRequest, +                          self._put_sync_stream, "{}\r\n]") + +        self.assertRaises(http_app.BadRequest, +                          self._put_sync_stream, "\r\n{}\r\n]") + +        self.assertRaises(http_app.BadRequest, +                          self._put_sync_stream, "") + +    def test_put_sync_stream_wrong_end(self): +        self.assertRaises(http_app.BadRequest, +                          self._put_sync_stream, "[\r\n{}") + +        self.assertRaises(http_app.BadRequest, +                          self._put_sync_stream, "[\r\n") + +        self.assertRaises(http_app.BadRequest, +                          self._put_sync_stream, "[\r\n{}\r\n]\r\n...") + +    def test_put_sync_stream_missing_comma(self): +        self.assertRaises(http_app.BadRequest, +                          self._put_sync_stream, "[\r\n{}\r\n{}\r\n]") + +    def test_put_sync_stream_extra_comma(self): +        self.assertRaises(http_app.BadRequest, +                          self._put_sync_stream, "[\r\n{},\r\n]") + +        self.assertRaises(http_app.BadRequest, +                          self._put_sync_stream, "[\r\n{},\r\n{},\r\n]") + +    def test_bad_request_decode_failure(self): +        resource = TestResource() +        environ = {'QUERY_STRING': 'a=\xff', 'REQUEST_METHOD': 'PUT', +                   'wsgi.input': StringIO.StringIO('{}'), +                   'CONTENT_LENGTH': '2', +                   'CONTENT_TYPE': 'application/json'} +        invoke = http_app.HTTPInvocationByMethodWithBody(resource, environ, +                                                         parameters) +        self.assertRaises(http_app.BadRequest, invoke) + +    def test_bad_request_unsupported_content_type(self): +        resource = TestResource() +        environ = {'QUERY_STRING': '', 'REQUEST_METHOD': 'PUT', +                   'wsgi.input': StringIO.StringIO('{}'), +                   'CONTENT_LENGTH': '2', +                   'CONTENT_TYPE': 'text/plain'} +        invoke = http_app.HTTPInvocationByMethodWithBody(resource, environ, +                                                         parameters) +        self.assertRaises(http_app.BadRequest, invoke) + +    def test_bad_request_content_length_too_large(self): +        resource = TestResource() +        environ = {'QUERY_STRING': '', 'REQUEST_METHOD': 'PUT', +                   'wsgi.input': StringIO.StringIO('{}'), +                   'CONTENT_LENGTH': '10000', +                   'CONTENT_TYPE': 'text/plain'} + +        resource.max_request_size = 5000 +        resource.max_entry_size = sys.maxint  # we don't get to use this + +        invoke = http_app.HTTPInvocationByMethodWithBody(resource, environ, +                                                         parameters) +        self.assertRaises(http_app.BadRequest, invoke) + +    def test_bad_request_no_content_length(self): +        resource = TestResource() +        environ = {'QUERY_STRING': '', 'REQUEST_METHOD': 'PUT', +                   'wsgi.input': StringIO.StringIO('a'), +                   'CONTENT_TYPE': 'application/json'} +        invoke = http_app.HTTPInvocationByMethodWithBody(resource, environ, +                                                         parameters) +        self.assertRaises(http_app.BadRequest, invoke) + +    def test_bad_request_invalid_content_length(self): +        resource = TestResource() +        environ = {'QUERY_STRING': '', 'REQUEST_METHOD': 'PUT', +                   'wsgi.input': StringIO.StringIO('abc'), +                   'CONTENT_LENGTH': '1unk', +                   'CONTENT_TYPE': 'application/json'} +        invoke = http_app.HTTPInvocationByMethodWithBody(resource, environ, +                                                         parameters) +        self.assertRaises(http_app.BadRequest, invoke) + +    def test_bad_request_empty_body(self): +        resource = TestResource() +        environ = {'QUERY_STRING': '', 'REQUEST_METHOD': 'PUT', +                   'wsgi.input': StringIO.StringIO(''), +                   'CONTENT_LENGTH': '0', +                   'CONTENT_TYPE': 'application/json'} +        invoke = http_app.HTTPInvocationByMethodWithBody(resource, environ, +                                                         parameters) +        self.assertRaises(http_app.BadRequest, invoke) + +    def test_bad_request_unsupported_method_get_like(self): +        resource = TestResource() +        environ = {'QUERY_STRING': '', 'REQUEST_METHOD': 'DELETE'} +        invoke = http_app.HTTPInvocationByMethodWithBody(resource, environ, +                                                         parameters) +        self.assertRaises(http_app.BadRequest, invoke) + +    def test_bad_request_unsupported_method_put_like(self): +        resource = TestResource() +        environ = {'QUERY_STRING': '', 'REQUEST_METHOD': 'PUT', +                   'wsgi.input': StringIO.StringIO('{}'), +                   'CONTENT_LENGTH': '2', +                   'CONTENT_TYPE': 'application/json'} +        invoke = http_app.HTTPInvocationByMethodWithBody(resource, environ, +                                                         parameters) +        self.assertRaises(http_app.BadRequest, invoke) + +    def test_bad_request_unsupported_method_put_like_multi_json(self): +        resource = TestResource() +        body = '{}\r\n{}\r\n' +        environ = {'QUERY_STRING': '', 'REQUEST_METHOD': 'POST', +                   'wsgi.input': StringIO.StringIO(body), +                   'CONTENT_LENGTH': str(len(body)), +                   'CONTENT_TYPE': 'application/x-u1db-multi-json'} +        invoke = http_app.HTTPInvocationByMethodWithBody(resource, environ, +                                                         parameters) +        self.assertRaises(http_app.BadRequest, invoke) + + +class TestHTTPResponder(tests.TestCase): + +    def start_response(self, status, headers): +        self.status = status +        self.headers = dict(headers) +        self.response_body = [] + +        def write(data): +            self.response_body.append(data) + +        return write + +    def test_send_response_content_w_headers(self): +        responder = http_app.HTTPResponder(self.start_response) +        responder.send_response_content('foo', headers={'x-a': '1'}) +        self.assertEqual('200 OK', self.status) +        self.assertEqual({'content-type': 'application/json', +                          'cache-control': 'no-cache', +                          'x-a': '1', 'content-length': '3'}, self.headers) +        self.assertEqual([], self.response_body) +        self.assertEqual(['foo'], responder.content) + +    def test_send_response_json(self): +        responder = http_app.HTTPResponder(self.start_response) +        responder.send_response_json(value='success') +        self.assertEqual('200 OK', self.status) +        expected_body = '{"value": "success"}\r\n' +        self.assertEqual({'content-type': 'application/json', +                          'content-length': str(len(expected_body)), +                          'cache-control': 'no-cache'}, self.headers) +        self.assertEqual([], self.response_body) +        self.assertEqual([expected_body], responder.content) + +    def test_send_response_json_status_fail(self): +        responder = http_app.HTTPResponder(self.start_response) +        responder.send_response_json(400) +        self.assertEqual('400 Bad Request', self.status) +        expected_body = '{}\r\n' +        self.assertEqual({'content-type': 'application/json', +                          'content-length': str(len(expected_body)), +                          'cache-control': 'no-cache'}, self.headers) +        self.assertEqual([], self.response_body) +        self.assertEqual([expected_body], responder.content) + +    def test_start_finish_response_status_fail(self): +        responder = http_app.HTTPResponder(self.start_response) +        responder.start_response(404, {'error': 'not found'}) +        responder.finish_response() +        self.assertEqual('404 Not Found', self.status) +        self.assertEqual({'content-type': 'application/json', +                          'cache-control': 'no-cache'}, self.headers) +        self.assertEqual(['{"error": "not found"}\r\n'], self.response_body) +        self.assertEqual([], responder.content) + +    def test_send_stream_entry(self): +        responder = http_app.HTTPResponder(self.start_response) +        responder.content_type = "application/x-u1db-multi-json" +        responder.start_response(200) +        responder.start_stream() +        responder.stream_entry({'entry': 1}) +        responder.stream_entry({'entry': 2}) +        responder.end_stream() +        responder.finish_response() +        self.assertEqual('200 OK', self.status) +        self.assertEqual({'content-type': 'application/x-u1db-multi-json', +                          'cache-control': 'no-cache'}, self.headers) +        self.assertEqual(['[', +                          '\r\n', '{"entry": 1}', +                          ',\r\n', '{"entry": 2}', +                          '\r\n]\r\n'], self.response_body) +        self.assertEqual([], responder.content) + +    def test_send_stream_w_error(self): +        responder = http_app.HTTPResponder(self.start_response) +        responder.content_type = "application/x-u1db-multi-json" +        responder.start_response(200) +        responder.start_stream() +        responder.stream_entry({'entry': 1}) +        responder.send_response_json(503, error="unavailable") +        self.assertEqual('200 OK', self.status) +        self.assertEqual({'content-type': 'application/x-u1db-multi-json', +                          'cache-control': 'no-cache'}, self.headers) +        self.assertEqual(['[', +                          '\r\n', '{"entry": 1}'], self.response_body) +        self.assertEqual([',\r\n', '{"error": "unavailable"}\r\n'], +                         responder.content) + + +class TestHTTPApp(tests.TestCase): + +    def setUp(self): +        super(TestHTTPApp, self).setUp() +        self.state = tests.ServerStateForTests() +        self.http_app = http_app.HTTPApp(self.state) +        self.app = paste.fixture.TestApp(self.http_app) +        self.db0 = self.state._create_database('db0') + +    def test_bad_request_broken(self): +        resp = self.app.put('/db0/doc/doc1', params='{"x": 1}', +                            headers={'content-type': 'application/foo'}, +                            expect_errors=True) +        self.assertEqual(400, resp.status) + +    def test_bad_request_dispatch(self): +        resp = self.app.put('/db0/foo/doc1', params='{"x": 1}', +                            headers={'content-type': 'application/json'}, +                            expect_errors=True) +        self.assertEqual(400, resp.status) + +    def test_version(self): +        resp = self.app.get('/') +        self.assertEqual(200, resp.status) +        self.assertEqual('application/json', resp.header('content-type')) +        self.assertEqual({"version": _u1db_version}, json.loads(resp.body)) + +    def test_create_database(self): +        resp = self.app.put('/db1', params='{}', +                            headers={'content-type': 'application/json'}) +        self.assertEqual(200, resp.status) +        self.assertEqual('application/json', resp.header('content-type')) +        self.assertEqual({'ok': True}, json.loads(resp.body)) + +        resp = self.app.put('/db1', params='{}', +                            headers={'content-type': 'application/json'}) +        self.assertEqual(200, resp.status) +        self.assertEqual('application/json', resp.header('content-type')) +        self.assertEqual({'ok': True}, json.loads(resp.body)) + +    def test_delete_database(self): +        resp = self.app.delete('/db0') +        self.assertEqual(200, resp.status) +        self.assertRaises(errors.DatabaseDoesNotExist, +                          self.state.check_database, 'db0') + +    def test_get_database(self): +        resp = self.app.get('/db0') +        self.assertEqual(200, resp.status) +        self.assertEqual('application/json', resp.header('content-type')) +        self.assertEqual({}, json.loads(resp.body)) + +    def test_valid_database_names(self): +        resp = self.app.get('/a-database', expect_errors=True) +        self.assertEqual(404, resp.status) + +        resp = self.app.get('/db1', expect_errors=True) +        self.assertEqual(404, resp.status) + +        resp = self.app.get('/0', expect_errors=True) +        self.assertEqual(404, resp.status) + +        resp = self.app.get('/0-0', expect_errors=True) +        self.assertEqual(404, resp.status) + +        resp = self.app.get('/org.future', expect_errors=True) +        self.assertEqual(404, resp.status) + +    def test_invalid_database_names(self): +        resp = self.app.get('/.a', expect_errors=True) +        self.assertEqual(400, resp.status) + +        resp = self.app.get('/-a', expect_errors=True) +        self.assertEqual(400, resp.status) + +        resp = self.app.get('/_a', expect_errors=True) +        self.assertEqual(400, resp.status) + +    def test_put_doc_create(self): +        resp = self.app.put('/db0/doc/doc1', params='{"x": 1}', +                            headers={'content-type': 'application/json'}) +        doc = self.db0.get_doc('doc1') +        self.assertEqual(201, resp.status)  # created +        self.assertEqual('{"x": 1}', doc.get_json()) +        self.assertEqual('application/json', resp.header('content-type')) +        self.assertEqual({'rev': doc.rev}, json.loads(resp.body)) + +    def test_put_doc(self): +        doc = self.db0.create_doc_from_json('{"x": 1}', doc_id='doc1') +        resp = self.app.put('/db0/doc/doc1?old_rev=%s' % doc.rev, +                            params='{"x": 2}', +                            headers={'content-type': 'application/json'}) +        doc = self.db0.get_doc('doc1') +        self.assertEqual(200, resp.status) +        self.assertEqual('{"x": 2}', doc.get_json()) +        self.assertEqual('application/json', resp.header('content-type')) +        self.assertEqual({'rev': doc.rev}, json.loads(resp.body)) + +    def test_put_doc_too_large(self): +        self.http_app.max_request_size = 15000 +        doc = self.db0.create_doc_from_json('{"x": 1}', doc_id='doc1') +        resp = self.app.put('/db0/doc/doc1?old_rev=%s' % doc.rev, +                            params='{"%s": 2}' % ('z' * 16000), +                            headers={'content-type': 'application/json'}, +                            expect_errors=True) +        self.assertEqual(400, resp.status) + +    def test_delete_doc(self): +        doc = self.db0.create_doc_from_json('{"x": 1}', doc_id='doc1') +        resp = self.app.delete('/db0/doc/doc1?old_rev=%s' % doc.rev) +        doc = self.db0.get_doc('doc1', include_deleted=True) +        self.assertEqual(None, doc.content) +        self.assertEqual(200, resp.status) +        self.assertEqual('application/json', resp.header('content-type')) +        self.assertEqual({'rev': doc.rev}, json.loads(resp.body)) + +    def test_get_doc(self): +        doc = self.db0.create_doc_from_json('{"x": 1}', doc_id='doc1') +        resp = self.app.get('/db0/doc/%s' % doc.doc_id) +        self.assertEqual(200, resp.status) +        self.assertEqual('application/json', resp.header('content-type')) +        self.assertEqual('{"x": 1}', resp.body) +        self.assertEqual(doc.rev, resp.header('x-u1db-rev')) +        self.assertEqual('false', resp.header('x-u1db-has-conflicts')) + +    def test_get_doc_non_existing(self): +        resp = self.app.get('/db0/doc/not-there', expect_errors=True) +        self.assertEqual(404, resp.status) +        self.assertEqual('application/json', resp.header('content-type')) +        self.assertEqual( +            {"error": "document does not exist"}, json.loads(resp.body)) +        self.assertEqual('', resp.header('x-u1db-rev')) +        self.assertEqual('false', resp.header('x-u1db-has-conflicts')) + +    def test_get_doc_deleted(self): +        doc = self.db0.create_doc_from_json('{"x": 1}', doc_id='doc1') +        self.db0.delete_doc(doc) +        resp = self.app.get('/db0/doc/doc1', expect_errors=True) +        self.assertEqual(404, resp.status) +        self.assertEqual('application/json', resp.header('content-type')) +        self.assertEqual( +            {"error": errors.DocumentDoesNotExist.wire_description}, +            json.loads(resp.body)) + +    def test_get_doc_deleted_explicit_exclude(self): +        doc = self.db0.create_doc_from_json('{"x": 1}', doc_id='doc1') +        self.db0.delete_doc(doc) +        resp = self.app.get( +            '/db0/doc/doc1?include_deleted=false', expect_errors=True) +        self.assertEqual(404, resp.status) +        self.assertEqual('application/json', resp.header('content-type')) +        self.assertEqual( +            {"error": errors.DocumentDoesNotExist.wire_description}, +            json.loads(resp.body)) + +    def test_get_deleted_doc(self): +        doc = self.db0.create_doc_from_json('{"x": 1}', doc_id='doc1') +        self.db0.delete_doc(doc) +        resp = self.app.get( +            '/db0/doc/doc1?include_deleted=true', expect_errors=True) +        self.assertEqual(404, resp.status) +        self.assertEqual('application/json', resp.header('content-type')) +        self.assertEqual( +            {"error": errors.DOCUMENT_DELETED}, json.loads(resp.body)) +        self.assertEqual(doc.rev, resp.header('x-u1db-rev')) +        self.assertEqual('false', resp.header('x-u1db-has-conflicts')) + +    def test_get_doc_non_existing_dabase(self): +        resp = self.app.get('/not-there/doc/doc1', expect_errors=True) +        self.assertEqual(404, resp.status) +        self.assertEqual('application/json', resp.header('content-type')) +        self.assertEqual( +            {"error": "database does not exist"}, json.loads(resp.body)) + +    def test_get_docs(self): +        doc1 = self.db0.create_doc_from_json('{"x": 1}', doc_id='doc1') +        doc2 = self.db0.create_doc_from_json('{"x": 1}', doc_id='doc2') +        ids = ','.join([doc1.doc_id, doc2.doc_id]) +        resp = self.app.get('/db0/docs?doc_ids=%s' % ids) +        self.assertEqual(200, resp.status) +        self.assertEqual( +            'application/json', resp.header('content-type')) +        expected = [ +            {"content": '{"x": 1}', "doc_rev": "db0:1", "doc_id": "doc1", +             "has_conflicts": False}, +            {"content": '{"x": 1}', "doc_rev": "db0:1", "doc_id": "doc2", +             "has_conflicts": False}] +        self.assertEqual(expected, json.loads(resp.body)) + +    def test_get_docs_missing_doc_ids(self): +        resp = self.app.get('/db0/docs', expect_errors=True) +        self.assertEqual(400, resp.status) +        self.assertEqual('application/json', resp.header('content-type')) +        self.assertEqual( +            {"error": "missing document ids"}, json.loads(resp.body)) + +    def test_get_docs_empty_doc_ids(self): +        resp = self.app.get('/db0/docs?doc_ids=', expect_errors=True) +        self.assertEqual(400, resp.status) +        self.assertEqual('application/json', resp.header('content-type')) +        self.assertEqual( +            {"error": "missing document ids"}, json.loads(resp.body)) + +    def test_get_docs_percent(self): +        doc1 = self.db0.create_doc_from_json('{"x": 1}', doc_id='doc%1') +        doc2 = self.db0.create_doc_from_json('{"x": 1}', doc_id='doc2') +        ids = ','.join([doc1.doc_id, doc2.doc_id]) +        resp = self.app.get('/db0/docs?doc_ids=%s' % ids) +        self.assertEqual(200, resp.status) +        self.assertEqual( +            'application/json', resp.header('content-type')) +        expected = [ +            {"content": '{"x": 1}', "doc_rev": "db0:1", "doc_id": "doc%1", +             "has_conflicts": False}, +            {"content": '{"x": 1}', "doc_rev": "db0:1", "doc_id": "doc2", +             "has_conflicts": False}] +        self.assertEqual(expected, json.loads(resp.body)) + +    def test_get_docs_deleted(self): +        doc1 = self.db0.create_doc_from_json('{"x": 1}', doc_id='doc1') +        doc2 = self.db0.create_doc_from_json('{"x": 1}', doc_id='doc2') +        self.db0.delete_doc(doc2) +        ids = ','.join([doc1.doc_id, doc2.doc_id]) +        resp = self.app.get('/db0/docs?doc_ids=%s' % ids) +        self.assertEqual(200, resp.status) +        self.assertEqual( +            'application/json', resp.header('content-type')) +        expected = [ +            {"content": '{"x": 1}', "doc_rev": "db0:1", "doc_id": "doc1", +             "has_conflicts": False}] +        self.assertEqual(expected, json.loads(resp.body)) + +    def test_get_docs_include_deleted(self): +        doc1 = self.db0.create_doc_from_json('{"x": 1}', doc_id='doc1') +        doc2 = self.db0.create_doc_from_json('{"x": 1}', doc_id='doc2') +        self.db0.delete_doc(doc2) +        ids = ','.join([doc1.doc_id, doc2.doc_id]) +        resp = self.app.get('/db0/docs?doc_ids=%s&include_deleted=true' % ids) +        self.assertEqual(200, resp.status) +        self.assertEqual( +            'application/json', resp.header('content-type')) +        expected = [ +            {"content": '{"x": 1}', "doc_rev": "db0:1", "doc_id": "doc1", +             "has_conflicts": False}, +            {"content": None, "doc_rev": "db0:2", "doc_id": "doc2", +             "has_conflicts": False}] +        self.assertEqual(expected, json.loads(resp.body)) + +    def test_get_sync_info(self): +        self.db0._set_replica_gen_and_trans_id('other-id', 1, 'T-transid') +        resp = self.app.get('/db0/sync-from/other-id') +        self.assertEqual(200, resp.status) +        self.assertEqual('application/json', resp.header('content-type')) +        self.assertEqual(dict(target_replica_uid='db0', +                              target_replica_generation=0, +                              target_replica_transaction_id='', +                              source_replica_uid='other-id', +                              source_replica_generation=1, +                              source_transaction_id='T-transid'), +                         json.loads(resp.body)) + +    def test_record_sync_info(self): +        resp = self.app.put('/db0/sync-from/other-id', +                            params='{"generation": 2, "transaction_id": ' +                                   '"T-transid"}', +                            headers={'content-type': 'application/json'}) +        self.assertEqual(200, resp.status) +        self.assertEqual('application/json', resp.header('content-type')) +        self.assertEqual({'ok': True}, json.loads(resp.body)) +        self.assertEqual( +            (2, 'T-transid'), +            self.db0._get_replica_gen_and_trans_id('other-id')) + +    def test_sync_exchange_send(self): +        entries = { +            10: {'id': 'doc-here', 'rev': 'replica:1', 'content': +                 '{"value": "here"}', 'gen': 10, 'trans_id': 'T-sid'}, +            11: {'id': 'doc-here2', 'rev': 'replica:1', 'content': +                 '{"value": "here2"}', 'gen': 11, 'trans_id': 'T-sed'} +        } + +        gens = [] +        _do_set_replica_gen_and_trans_id = \ +            self.db0._do_set_replica_gen_and_trans_id + +        def set_sync_generation_witness(other_uid, other_gen, other_trans_id): +            gens.append((other_uid, other_gen)) +            _do_set_replica_gen_and_trans_id( +                other_uid, other_gen, other_trans_id) +            self.assertGetDoc(self.db0, entries[other_gen]['id'], +                              entries[other_gen]['rev'], +                              entries[other_gen]['content'], False) + +        self.patch( +            self.db0, '_do_set_replica_gen_and_trans_id', +            set_sync_generation_witness) + +        args = dict(last_known_generation=0) +        body = ("[\r\n" + +                "%s,\r\n" % json.dumps(args) + +                "%s,\r\n" % json.dumps(entries[10]) + +                "%s\r\n" % json.dumps(entries[11]) + +                "]\r\n") +        resp = self.app.post('/db0/sync-from/replica', +                             params=body, +                             headers={'content-type': +                                      'application/x-u1db-sync-stream'}) +        self.assertEqual(200, resp.status) +        self.assertEqual('application/x-u1db-sync-stream', +                         resp.header('content-type')) +        bits = resp.body.split('\r\n') +        self.assertEqual('[', bits[0]) +        last_trans_id = self.db0._get_transaction_log()[-1][1] +        self.assertEqual({'new_generation': 2, +                          'new_transaction_id': last_trans_id}, +                         json.loads(bits[1])) +        self.assertEqual(']', bits[2]) +        self.assertEqual('', bits[3]) +        self.assertEqual([('replica', 10), ('replica', 11)], gens) + +    def test_sync_exchange_send_ensure(self): +        entries = { +            10: {'id': 'doc-here', 'rev': 'replica:1', 'content': +                 '{"value": "here"}', 'gen': 10, 'trans_id': 'T-sid'}, +            11: {'id': 'doc-here2', 'rev': 'replica:1', 'content': +                 '{"value": "here2"}', 'gen': 11, 'trans_id': 'T-sed'} +        } + +        args = dict(last_known_generation=0, ensure=True) +        body = ("[\r\n" + +                "%s,\r\n" % json.dumps(args) + +                "%s,\r\n" % json.dumps(entries[10]) + +                "%s\r\n" % json.dumps(entries[11]) + +                "]\r\n") +        resp = self.app.post('/dbnew/sync-from/replica', +                             params=body, +                             headers={'content-type': +                                      'application/x-u1db-sync-stream'}) +        self.assertEqual(200, resp.status) +        self.assertEqual('application/x-u1db-sync-stream', +                         resp.header('content-type')) +        bits = resp.body.split('\r\n') +        self.assertEqual('[', bits[0]) +        dbnew = self.state.open_database("dbnew") +        last_trans_id = dbnew._get_transaction_log()[-1][1] +        self.assertEqual({'new_generation': 2, +                          'new_transaction_id': last_trans_id, +                          'replica_uid': dbnew._replica_uid}, +                         json.loads(bits[1])) +        self.assertEqual(']', bits[2]) +        self.assertEqual('', bits[3]) + +    def test_sync_exchange_send_entry_too_large(self): +        self.patch(http_app.SyncResource, 'max_request_size', 20000) +        self.patch(http_app.SyncResource, 'max_entry_size', 10000) +        entries = { +            10: {'id': 'doc-here', 'rev': 'replica:1', 'content': +                 '{"value": "%s"}' % ('H' * 11000), 'gen': 10}, +        } +        args = dict(last_known_generation=0) +        body = ("[\r\n" + +                "%s,\r\n" % json.dumps(args) + +                "%s\r\n" % json.dumps(entries[10]) + +                "]\r\n") +        resp = self.app.post('/db0/sync-from/replica', +                             params=body, +                             headers={'content-type': +                                      'application/x-u1db-sync-stream'}, +                             expect_errors=True) +        self.assertEqual(400, resp.status) + +    def test_sync_exchange_receive(self): +        doc = self.db0.create_doc_from_json('{"value": "there"}') +        doc2 = self.db0.create_doc_from_json('{"value": "there2"}') +        args = dict(last_known_generation=0) +        body = "[\r\n%s\r\n]" % json.dumps(args) +        resp = self.app.post('/db0/sync-from/replica', +                             params=body, +                             headers={'content-type': +                                      'application/x-u1db-sync-stream'}) +        self.assertEqual(200, resp.status) +        self.assertEqual('application/x-u1db-sync-stream', +                         resp.header('content-type')) +        parts = resp.body.splitlines() +        self.assertEqual(5, len(parts)) +        self.assertEqual('[', parts[0]) +        last_trans_id = self.db0._get_transaction_log()[-1][1] +        self.assertEqual({'new_generation': 2, +                          'new_transaction_id': last_trans_id}, +                         json.loads(parts[1].rstrip(","))) +        part2 = json.loads(parts[2].rstrip(",")) +        self.assertTrue(part2['trans_id'].startswith('T-')) +        self.assertEqual('{"value": "there"}', part2['content']) +        self.assertEqual(doc.rev, part2['rev']) +        self.assertEqual(doc.doc_id, part2['id']) +        self.assertEqual(1, part2['gen']) +        part3 = json.loads(parts[3].rstrip(",")) +        self.assertTrue(part3['trans_id'].startswith('T-')) +        self.assertEqual('{"value": "there2"}', part3['content']) +        self.assertEqual(doc2.rev, part3['rev']) +        self.assertEqual(doc2.doc_id, part3['id']) +        self.assertEqual(2, part3['gen']) +        self.assertEqual(']', parts[4]) + +    def test_sync_exchange_error_in_stream(self): +        args = dict(last_known_generation=0) +        body = "[\r\n%s\r\n]" % json.dumps(args) + +        def boom(self, return_doc_cb): +            raise errors.Unavailable + +        self.patch(sync.SyncExchange, 'return_docs', +                   boom) +        resp = self.app.post('/db0/sync-from/replica', +                             params=body, +                             headers={'content-type': +                                      'application/x-u1db-sync-stream'}) +        self.assertEqual(200, resp.status) +        self.assertEqual('application/x-u1db-sync-stream', +                         resp.header('content-type')) +        parts = resp.body.splitlines() +        self.assertEqual(3, len(parts)) +        self.assertEqual('[', parts[0]) +        self.assertEqual({'new_generation': 0, 'new_transaction_id': ''}, +                         json.loads(parts[1].rstrip(","))) +        self.assertEqual({'error': 'unavailable'}, json.loads(parts[2])) + + +class TestRequestHooks(tests.TestCase): + +    def setUp(self): +        super(TestRequestHooks, self).setUp() +        self.state = tests.ServerStateForTests() +        self.http_app = http_app.HTTPApp(self.state) +        self.app = paste.fixture.TestApp(self.http_app) +        self.db0 = self.state._create_database('db0') + +    def test_begin_and_done(self): +        calls = [] + +        def begin(environ): +            self.assertTrue('PATH_INFO' in environ) +            calls.append('begin') + +        def done(environ): +            self.assertTrue('PATH_INFO' in environ) +            calls.append('done') + +        self.http_app.request_begin = begin +        self.http_app.request_done = done + +        doc = self.db0.create_doc_from_json('{"x": 1}', doc_id='doc1') +        self.app.get('/db0/doc/%s' % doc.doc_id) + +        self.assertEqual(['begin', 'done'], calls) + +    def test_bad_request(self): +        calls = [] + +        def begin(environ): +            self.assertTrue('PATH_INFO' in environ) +            calls.append('begin') + +        def bad_request(environ): +            self.assertTrue('PATH_INFO' in environ) +            calls.append('bad-request') + +        self.http_app.request_begin = begin +        self.http_app.request_bad_request = bad_request +        # shouldn't be called +        self.http_app.request_done = lambda env: 1 / 0 + +        resp = self.app.put('/db0/foo/doc1', params='{"x": 1}', +                            headers={'content-type': 'application/json'}, +                            expect_errors=True) +        self.assertEqual(400, resp.status) +        self.assertEqual(['begin', 'bad-request'], calls) + + +class TestHTTPErrors(tests.TestCase): + +    def test_wire_description_to_status(self): +        self.assertNotIn("error", http_errors.wire_description_to_status) + + +class TestHTTPAppErrorHandling(tests.TestCase): + +    def setUp(self): +        super(TestHTTPAppErrorHandling, self).setUp() +        self.exc = None +        self.state = tests.ServerStateForTests() + +        class ErroringResource(object): + +            def post(_, args, content): +                raise self.exc + +        def lookup_resource(environ, responder): +            return ErroringResource() + +        self.http_app = http_app.HTTPApp(self.state) +        self.http_app._lookup_resource = lookup_resource +        self.app = paste.fixture.TestApp(self.http_app) + +    def test_RevisionConflict_etc(self): +        self.exc = errors.RevisionConflict() +        resp = self.app.post('/req', params='{}', +                             headers={'content-type': 'application/json'}, +                             expect_errors=True) +        self.assertEqual(409, resp.status) +        self.assertEqual('application/json', resp.header('content-type')) +        self.assertEqual({"error": "revision conflict"}, +                         json.loads(resp.body)) + +    def test_Unavailable(self): +        self.exc = errors.Unavailable +        resp = self.app.post('/req', params='{}', +                             headers={'content-type': 'application/json'}, +                             expect_errors=True) +        self.assertEqual(503, resp.status) +        self.assertEqual('application/json', resp.header('content-type')) +        self.assertEqual({"error": "unavailable"}, +                         json.loads(resp.body)) + +    def test_generic_u1db_errors(self): +        self.exc = errors.U1DBError() +        resp = self.app.post('/req', params='{}', +                             headers={'content-type': 'application/json'}, +                             expect_errors=True) +        self.assertEqual(500, resp.status) +        self.assertEqual('application/json', resp.header('content-type')) +        self.assertEqual({"error": "error"}, +                         json.loads(resp.body)) + +    def test_generic_u1db_errors_hooks(self): +        calls = [] + +        def begin(environ): +            self.assertTrue('PATH_INFO' in environ) +            calls.append('begin') + +        def u1db_error(environ, exc): +            self.assertTrue('PATH_INFO' in environ) +            calls.append(('error', exc)) + +        self.http_app.request_begin = begin +        self.http_app.request_u1db_error = u1db_error +        # shouldn't be called +        self.http_app.request_done = lambda env: 1 / 0 + +        self.exc = errors.U1DBError() +        resp = self.app.post('/req', params='{}', +                             headers={'content-type': 'application/json'}, +                             expect_errors=True) +        self.assertEqual(500, resp.status) +        self.assertEqual(['begin', ('error', self.exc)], calls) + +    def test_failure(self): +        class Failure(Exception): +            pass +        self.exc = Failure() +        self.assertRaises(Failure, self.app.post, '/req', params='{}', +                          headers={'content-type': 'application/json'}) + +    def test_failure_hooks(self): +        class Failure(Exception): +            pass +        calls = [] + +        def begin(environ): +            calls.append('begin') + +        def failed(environ): +            self.assertTrue('PATH_INFO' in environ) +            calls.append(('failed', sys.exc_info())) + +        self.http_app.request_begin = begin +        self.http_app.request_failed = failed +        # shouldn't be called +        self.http_app.request_done = lambda env: 1 / 0 + +        self.exc = Failure() +        self.assertRaises(Failure, self.app.post, '/req', params='{}', +                          headers={'content-type': 'application/json'}) + +        self.assertEqual(2, len(calls)) +        self.assertEqual('begin', calls[0]) +        marker, (exc_type, exc, tb) = calls[1] +        self.assertEqual('failed', marker) +        self.assertEqual(self.exc, exc) + + +class TestPluggableSyncExchange(tests.TestCase): + +    def setUp(self): +        super(TestPluggableSyncExchange, self).setUp() +        self.state = tests.ServerStateForTests() +        self.state.ensure_database('foo') + +    def test_plugging(self): + +        class MySyncExchange(object): +            def __init__(self, db, source_replica_uid, last_known_generation): +                pass + +        class MySyncResource(http_app.SyncResource): +            sync_exchange_class = MySyncExchange + +        sync_res = MySyncResource('foo', 'src', self.state, None) +        sync_res.post_args( +            {'last_known_generation': 0, 'last_known_trans_id': None}, '{}') +        self.assertIsInstance(sync_res.sync_exch, MySyncExchange) diff --git a/common/src/leap/soledad/common/tests/u1db_tests/test_http_client.py b/common/src/leap/soledad/common/tests/u1db_tests/test_http_client.py new file mode 100644 index 00000000..08e9714e --- /dev/null +++ b/common/src/leap/soledad/common/tests/u1db_tests/test_http_client.py @@ -0,0 +1,363 @@ +# Copyright 2011-2012 Canonical Ltd. +# +# This file is part of u1db. +# +# u1db is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License version 3 +# as published by the Free Software Foundation. +# +# u1db is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with u1db.  If not, see <http://www.gnu.org/licenses/>. + +"""Tests for HTTPDatabase""" + +from oauth import oauth +try: +    import simplejson as json +except ImportError: +    import json  # noqa + +from u1db import ( +    errors, +) + +from leap.soledad.common.tests import u1db_tests as tests + +from u1db.remote import ( +    http_client, +) + + +class TestEncoder(tests.TestCase): + +    def test_encode_string(self): +        self.assertEqual("foo", http_client._encode_query_parameter("foo")) + +    def test_encode_true(self): +        self.assertEqual("true", http_client._encode_query_parameter(True)) + +    def test_encode_false(self): +        self.assertEqual("false", http_client._encode_query_parameter(False)) + + +class TestHTTPClientBase(tests.TestCaseWithServer): + +    def setUp(self): +        super(TestHTTPClientBase, self).setUp() +        self.errors = 0 + +    def app(self, environ, start_response): +        if environ['PATH_INFO'].endswith('echo'): +            start_response("200 OK", [('Content-Type', 'application/json')]) +            ret = {} +            for name in ('REQUEST_METHOD', 'PATH_INFO', 'QUERY_STRING'): +                ret[name] = environ[name] +            if environ['REQUEST_METHOD'] in ('PUT', 'POST'): +                ret['CONTENT_TYPE'] = environ['CONTENT_TYPE'] +                content_length = int(environ['CONTENT_LENGTH']) +                ret['body'] = environ['wsgi.input'].read(content_length) +            return [json.dumps(ret)] +        elif environ['PATH_INFO'].endswith('error_then_accept'): +            if self.errors >= 3: +                start_response( +                    "200 OK", [('Content-Type', 'application/json')]) +                ret = {} +                for name in ('REQUEST_METHOD', 'PATH_INFO', 'QUERY_STRING'): +                    ret[name] = environ[name] +                if environ['REQUEST_METHOD'] in ('PUT', 'POST'): +                    ret['CONTENT_TYPE'] = environ['CONTENT_TYPE'] +                    content_length = int(environ['CONTENT_LENGTH']) +                    ret['body'] = '{"oki": "doki"}' +                return [json.dumps(ret)] +            self.errors += 1 +            content_length = int(environ['CONTENT_LENGTH']) +            error = json.loads( +                environ['wsgi.input'].read(content_length)) +            response = error['response'] +            # In debug mode, wsgiref has an assertion that the status parameter +            # is a 'str' object. However error['status'] returns a unicode +            # object. +            status = str(error['status']) +            if isinstance(response, unicode): +                response = str(response) +            if isinstance(response, str): +                start_response(status, [('Content-Type', 'text/plain')]) +                return [str(response)] +            else: +                start_response(status, [('Content-Type', 'application/json')]) +                return [json.dumps(response)] +        elif environ['PATH_INFO'].endswith('error'): +            self.errors += 1 +            content_length = int(environ['CONTENT_LENGTH']) +            error = json.loads( +                environ['wsgi.input'].read(content_length)) +            response = error['response'] +            # In debug mode, wsgiref has an assertion that the status parameter +            # is a 'str' object. However error['status'] returns a unicode +            # object. +            status = str(error['status']) +            if isinstance(response, unicode): +                response = str(response) +            if isinstance(response, str): +                start_response(status, [('Content-Type', 'text/plain')]) +                return [str(response)] +            else: +                start_response(status, [('Content-Type', 'application/json')]) +                return [json.dumps(response)] +        elif '/oauth' in environ['PATH_INFO']: +            base_url = self.getURL('').rstrip('/') +            oauth_req = oauth.OAuthRequest.from_request( +                http_method=environ['REQUEST_METHOD'], +                http_url=base_url + environ['PATH_INFO'], +                headers={'Authorization': environ['HTTP_AUTHORIZATION']}, +                query_string=environ['QUERY_STRING'] +            ) +            oauth_server = oauth.OAuthServer(tests.testingOAuthStore) +            oauth_server.add_signature_method(tests.sign_meth_HMAC_SHA1) +            try: +                consumer, token, params = oauth_server.verify_request( +                    oauth_req) +            except oauth.OAuthError, e: +                start_response("401 Unauthorized", +                               [('Content-Type', 'application/json')]) +                return [json.dumps({"error": "unauthorized", +                                    "message": e.message})] +            start_response("200 OK", [('Content-Type', 'application/json')]) +            return [json.dumps([environ['PATH_INFO'], token.key, params])] + +    def make_app(self): +        return self.app + +    def getClient(self, **kwds): +        self.startServer() +        return http_client.HTTPClientBase(self.getURL('dbase'), **kwds) + +    def test_construct(self): +        self.startServer() +        url = self.getURL() +        cli = http_client.HTTPClientBase(url) +        self.assertEqual(url, cli._url.geturl()) +        self.assertIs(None, cli._conn) + +    def test_parse_url(self): +        cli = http_client.HTTPClientBase( +            '%s://127.0.0.1:12345/' % self.url_scheme) +        self.assertEqual(self.url_scheme, cli._url.scheme) +        self.assertEqual('127.0.0.1', cli._url.hostname) +        self.assertEqual(12345, cli._url.port) +        self.assertEqual('/', cli._url.path) + +    def test__ensure_connection(self): +        cli = self.getClient() +        self.assertIs(None, cli._conn) +        cli._ensure_connection() +        self.assertIsNot(None, cli._conn) +        conn = cli._conn +        cli._ensure_connection() +        self.assertIs(conn, cli._conn) + +    def test_close(self): +        cli = self.getClient() +        cli._ensure_connection() +        cli.close() +        self.assertIs(None, cli._conn) + +    def test__request(self): +        cli = self.getClient() +        res, headers = cli._request('PUT', ['echo'], {}, {}) +        self.assertEqual({'CONTENT_TYPE': 'application/json', +                          'PATH_INFO': '/dbase/echo', +                          'QUERY_STRING': '', +                          'body': '{}', +                          'REQUEST_METHOD': 'PUT'}, json.loads(res)) + +        res, headers = cli._request('GET', ['doc', 'echo'], {'a': 1}) +        self.assertEqual({'PATH_INFO': '/dbase/doc/echo', +                          'QUERY_STRING': 'a=1', +                          'REQUEST_METHOD': 'GET'}, json.loads(res)) + +        res, headers = cli._request('GET', ['doc', '%FFFF', 'echo'], {'a': 1}) +        self.assertEqual({'PATH_INFO': '/dbase/doc/%FFFF/echo', +                          'QUERY_STRING': 'a=1', +                          'REQUEST_METHOD': 'GET'}, json.loads(res)) + +        res, headers = cli._request('POST', ['echo'], {'b': 2}, 'Body', +                                    'application/x-test') +        self.assertEqual({'CONTENT_TYPE': 'application/x-test', +                          'PATH_INFO': '/dbase/echo', +                          'QUERY_STRING': 'b=2', +                          'body': 'Body', +                          'REQUEST_METHOD': 'POST'}, json.loads(res)) + +    def test__request_json(self): +        cli = self.getClient() +        res, headers = cli._request_json( +            'POST', ['echo'], {'b': 2}, {'a': 'x'}) +        self.assertEqual('application/json', headers['content-type']) +        self.assertEqual({'CONTENT_TYPE': 'application/json', +                          'PATH_INFO': '/dbase/echo', +                          'QUERY_STRING': 'b=2', +                          'body': '{"a": "x"}', +                          'REQUEST_METHOD': 'POST'}, res) + +    def test_unspecified_http_error(self): +        cli = self.getClient() +        self.assertRaises(errors.HTTPError, +                          cli._request_json, 'POST', ['error'], {}, +                          {'status': "500 Internal Error", +                           'response': "Crash."}) +        try: +            cli._request_json('POST', ['error'], {}, +                              {'status': "500 Internal Error", +                               'response': "Fail."}) +        except errors.HTTPError, e: +            pass + +        self.assertEqual(500, e.status) +        self.assertEqual("Fail.", e.message) +        self.assertTrue("content-type" in e.headers) + +    def test_revision_conflict(self): +        cli = self.getClient() +        self.assertRaises(errors.RevisionConflict, +                          cli._request_json, 'POST', ['error'], {}, +                          {'status': "409 Conflict", +                           'response': {"error": "revision conflict"}}) + +    def test_unavailable_proper(self): +        cli = self.getClient() +        cli._delays = (0, 0, 0, 0, 0) +        self.assertRaises(errors.Unavailable, +                          cli._request_json, 'POST', ['error'], {}, +                          {'status': "503 Service Unavailable", +                           'response': {"error": "unavailable"}}) +        self.assertEqual(5, self.errors) + +    def test_unavailable_then_available(self): +        cli = self.getClient() +        cli._delays = (0, 0, 0, 0, 0) +        res, headers = cli._request_json( +            'POST', ['error_then_accept'], {'b': 2}, +            {'status': "503 Service Unavailable", +             'response': {"error": "unavailable"}}) +        self.assertEqual('application/json', headers['content-type']) +        self.assertEqual({'CONTENT_TYPE': 'application/json', +                          'PATH_INFO': '/dbase/error_then_accept', +                          'QUERY_STRING': 'b=2', +                          'body': '{"oki": "doki"}', +                          'REQUEST_METHOD': 'POST'}, res) +        self.assertEqual(3, self.errors) + +    def test_unavailable_random_source(self): +        cli = self.getClient() +        cli._delays = (0, 0, 0, 0, 0) +        try: +            cli._request_json('POST', ['error'], {}, +                              {'status': "503 Service Unavailable", +                               'response': "random unavailable."}) +        except errors.Unavailable, e: +            pass + +        self.assertEqual(503, e.status) +        self.assertEqual("random unavailable.", e.message) +        self.assertTrue("content-type" in e.headers) +        self.assertEqual(5, self.errors) + +    def test_document_too_big(self): +        cli = self.getClient() +        self.assertRaises(errors.DocumentTooBig, +                          cli._request_json, 'POST', ['error'], {}, +                          {'status': "403 Forbidden", +                           'response': {"error": "document too big"}}) + +    def test_user_quota_exceeded(self): +        cli = self.getClient() +        self.assertRaises(errors.UserQuotaExceeded, +                          cli._request_json, 'POST', ['error'], {}, +                          {'status': "403 Forbidden", +                           'response': {"error": "user quota exceeded"}}) + +    def test_user_needs_subscription(self): +        cli = self.getClient() +        self.assertRaises(errors.SubscriptionNeeded, +                          cli._request_json, 'POST', ['error'], {}, +                          {'status': "403 Forbidden", +                           'response': {"error": "user needs subscription"}}) + +    def test_generic_u1db_error(self): +        cli = self.getClient() +        self.assertRaises(errors.U1DBError, +                          cli._request_json, 'POST', ['error'], {}, +                          {'status': "400 Bad Request", +                           'response': {"error": "error"}}) +        try: +            cli._request_json('POST', ['error'], {}, +                              {'status': "400 Bad Request", +                               'response': {"error": "error"}}) +        except errors.U1DBError, e: +            pass +        self.assertIs(e.__class__, errors.U1DBError) + +    def test_unspecified_bad_request(self): +        cli = self.getClient() +        self.assertRaises(errors.HTTPError, +                          cli._request_json, 'POST', ['error'], {}, +                          {'status': "400 Bad Request", +                           'response': "<Bad Request>"}) +        try: +            cli._request_json('POST', ['error'], {}, +                              {'status': "400 Bad Request", +                               'response': "<Bad Request>"}) +        except errors.HTTPError, e: +            pass + +        self.assertEqual(400, e.status) +        self.assertEqual("<Bad Request>", e.message) +        self.assertTrue("content-type" in e.headers) + +    def test_oauth(self): +        cli = self.getClient() +        cli.set_oauth_credentials(tests.consumer1.key, tests.consumer1.secret, +                                  tests.token1.key, tests.token1.secret) +        params = {'x': u'\xf0', 'y': "foo"} +        res, headers = cli._request('GET', ['doc', 'oauth'], params) +        self.assertEqual( +            ['/dbase/doc/oauth', tests.token1.key, params], json.loads(res)) + +        # oauth does its own internal quoting +        params = {'x': u'\xf0', 'y': "foo"} +        res, headers = cli._request('GET', ['doc', 'oauth', 'foo bar'], params) +        self.assertEqual( +            ['/dbase/doc/oauth/foo bar', tests.token1.key, params], +            json.loads(res)) + +    def test_oauth_ctr_creds(self): +        cli = self.getClient(creds={'oauth': { +            'consumer_key': tests.consumer1.key, +            'consumer_secret': tests.consumer1.secret, +            'token_key': tests.token1.key, +            'token_secret': tests.token1.secret, +        }}) +        params = {'x': u'\xf0', 'y': "foo"} +        res, headers = cli._request('GET', ['doc', 'oauth'], params) +        self.assertEqual( +            ['/dbase/doc/oauth', tests.token1.key, params], json.loads(res)) + +    def test_unknown_creds(self): +        self.assertRaises(errors.UnknownAuthMethod, +                          self.getClient, creds={'foo': {}}) +        self.assertRaises(errors.UnknownAuthMethod, +                          self.getClient, creds={}) + +    def test_oauth_Unauthorized(self): +        cli = self.getClient() +        cli.set_oauth_credentials(tests.consumer1.key, tests.consumer1.secret, +                                  tests.token1.key, "WRONG") +        params = {'y': 'foo'} +        self.assertRaises(errors.Unauthorized, cli._request, 'GET', +                          ['doc', 'oauth'], params) diff --git a/common/src/leap/soledad/common/tests/u1db_tests/test_http_database.py b/common/src/leap/soledad/common/tests/u1db_tests/test_http_database.py new file mode 100644 index 00000000..3f3c7bba --- /dev/null +++ b/common/src/leap/soledad/common/tests/u1db_tests/test_http_database.py @@ -0,0 +1,260 @@ +# Copyright 2011 Canonical Ltd. +# +# This file is part of u1db. +# +# u1db is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License version 3 +# as published by the Free Software Foundation. +# +# u1db is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with u1db.  If not, see <http://www.gnu.org/licenses/>. + +"""Tests for HTTPDatabase""" + +import inspect +try: +    import simplejson as json +except ImportError: +    import json  # noqa + +from u1db import ( +    errors, +    Document, +) + +from leap.soledad.common.tests import u1db_tests as tests + +from u1db.remote import ( +    http_database, +    http_target, +) +from leap.soledad.common.tests.u1db_tests.test_remote_sync_target import ( +    make_http_app, +) + + +class TestHTTPDatabaseSimpleOperations(tests.TestCase): + +    def setUp(self): +        super(TestHTTPDatabaseSimpleOperations, self).setUp() +        self.db = http_database.HTTPDatabase('dbase') +        self.db._conn = object()  # crash if used +        self.got = None +        self.response_val = None + +        def _request(method, url_parts, params=None, body=None, +                     content_type=None): +            self.got = method, url_parts, params, body, content_type +            if isinstance(self.response_val, Exception): +                raise self.response_val +            return self.response_val + +        def _request_json(method, url_parts, params=None, body=None, +                          content_type=None): +            self.got = method, url_parts, params, body, content_type +            if isinstance(self.response_val, Exception): +                raise self.response_val +            return self.response_val + +        self.db._request = _request +        self.db._request_json = _request_json + +    def test__sanity_same_signature(self): +        my_request_sig = inspect.getargspec(self.db._request) +        my_request_sig = (['self'] + my_request_sig[0],) + my_request_sig[1:] +        self.assertEqual( +            my_request_sig, +            inspect.getargspec(http_database.HTTPDatabase._request)) +        my_request_json_sig = inspect.getargspec(self.db._request_json) +        my_request_json_sig = ((['self'] + my_request_json_sig[0],) + +                               my_request_json_sig[1:]) +        self.assertEqual( +            my_request_json_sig, +            inspect.getargspec(http_database.HTTPDatabase._request_json)) + +    def test__ensure(self): +        self.response_val = {'ok': True}, {} +        self.db._ensure() +        self.assertEqual(('PUT', [], {}, {}, None), self.got) + +    def test__delete(self): +        self.response_val = {'ok': True}, {} +        self.db._delete() +        self.assertEqual(('DELETE', [], {}, {}, None), self.got) + +    def test__check(self): +        self.response_val = {}, {} +        res = self.db._check() +        self.assertEqual({}, res) +        self.assertEqual(('GET', [], None, None, None), self.got) + +    def test_put_doc(self): +        self.response_val = {'rev': 'doc-rev'}, {} +        doc = Document('doc-id', None, '{"v": 1}') +        res = self.db.put_doc(doc) +        self.assertEqual('doc-rev', res) +        self.assertEqual('doc-rev', doc.rev) +        self.assertEqual(('PUT', ['doc', 'doc-id'], {}, +                          '{"v": 1}', 'application/json'), self.got) + +        self.response_val = {'rev': 'doc-rev-2'}, {} +        doc.content = {"v": 2} +        res = self.db.put_doc(doc) +        self.assertEqual('doc-rev-2', res) +        self.assertEqual('doc-rev-2', doc.rev) +        self.assertEqual(('PUT', ['doc', 'doc-id'], {'old_rev': 'doc-rev'}, +                          '{"v": 2}', 'application/json'), self.got) + +    def test_get_doc(self): +        self.response_val = '{"v": 2}', {'x-u1db-rev': 'doc-rev', +                                         'x-u1db-has-conflicts': 'false'} +        self.assertGetDoc(self.db, 'doc-id', 'doc-rev', '{"v": 2}', False) +        self.assertEqual( +            ('GET', ['doc', 'doc-id'], {'include_deleted': False}, None, None), +            self.got) + +    def test_get_doc_non_existing(self): +        self.response_val = errors.DocumentDoesNotExist() +        self.assertIs(None, self.db.get_doc('not-there')) +        self.assertEqual( +            ('GET', ['doc', 'not-there'], {'include_deleted': False}, None, +             None), self.got) + +    def test_get_doc_deleted(self): +        self.response_val = errors.DocumentDoesNotExist() +        self.assertIs(None, self.db.get_doc('deleted')) +        self.assertEqual( +            ('GET', ['doc', 'deleted'], {'include_deleted': False}, None, +             None), self.got) + +    def test_get_doc_deleted_include_deleted(self): +        self.response_val = errors.HTTPError(404, +                                             json.dumps( +                                             {"error": errors.DOCUMENT_DELETED} +                                             ), +                                             {'x-u1db-rev': 'doc-rev-gone', +                                              'x-u1db-has-conflicts': 'false'}) +        doc = self.db.get_doc('deleted', include_deleted=True) +        self.assertEqual('deleted', doc.doc_id) +        self.assertEqual('doc-rev-gone', doc.rev) +        self.assertIs(None, doc.content) +        self.assertEqual( +            ('GET', ['doc', 'deleted'], {'include_deleted': True}, None, None), +            self.got) + +    def test_get_doc_pass_through_errors(self): +        self.response_val = errors.HTTPError(500, 'Crash.') +        self.assertRaises(errors.HTTPError, +                          self.db.get_doc, 'something-something') + +    def test_create_doc_with_id(self): +        self.response_val = {'rev': 'doc-rev'}, {} +        new_doc = self.db.create_doc_from_json('{"v": 1}', doc_id='doc-id') +        self.assertEqual('doc-rev', new_doc.rev) +        self.assertEqual('doc-id', new_doc.doc_id) +        self.assertEqual('{"v": 1}', new_doc.get_json()) +        self.assertEqual(('PUT', ['doc', 'doc-id'], {}, +                          '{"v": 1}', 'application/json'), self.got) + +    def test_create_doc_without_id(self): +        self.response_val = {'rev': 'doc-rev-2'}, {} +        new_doc = self.db.create_doc_from_json('{"v": 3}') +        self.assertEqual('D-', new_doc.doc_id[:2]) +        self.assertEqual('doc-rev-2', new_doc.rev) +        self.assertEqual('{"v": 3}', new_doc.get_json()) +        self.assertEqual(('PUT', ['doc', new_doc.doc_id], {}, +                          '{"v": 3}', 'application/json'), self.got) + +    def test_delete_doc(self): +        self.response_val = {'rev': 'doc-rev-gone'}, {} +        doc = Document('doc-id', 'doc-rev', None) +        self.db.delete_doc(doc) +        self.assertEqual('doc-rev-gone', doc.rev) +        self.assertEqual(('DELETE', ['doc', 'doc-id'], {'old_rev': 'doc-rev'}, +                          None, None), self.got) + +    def test_get_sync_target(self): +        st = self.db.get_sync_target() +        self.assertIsInstance(st, http_target.HTTPSyncTarget) +        self.assertEqual(st._url, self.db._url) + +    def test_get_sync_target_inherits_oauth_credentials(self): +        self.db.set_oauth_credentials(tests.consumer1.key, +                                      tests.consumer1.secret, +                                      tests.token1.key, tests.token1.secret) +        st = self.db.get_sync_target() +        self.assertEqual(self.db._creds, st._creds) + + +class TestHTTPDatabaseCtrWithCreds(tests.TestCase): + +    def test_ctr_with_creds(self): +        db1 = http_database.HTTPDatabase('http://dbs/db', creds={'oauth': { +            'consumer_key': tests.consumer1.key, +            'consumer_secret': tests.consumer1.secret, +            'token_key': tests.token1.key, +            'token_secret': tests.token1.secret +        }}) +        self.assertIn('oauth', db1._creds) + + +class TestHTTPDatabaseIntegration(tests.TestCaseWithServer): + +    make_app_with_state = staticmethod(make_http_app) + +    def setUp(self): +        super(TestHTTPDatabaseIntegration, self).setUp() +        self.startServer() + +    def test_non_existing_db(self): +        db = http_database.HTTPDatabase(self.getURL('not-there')) +        self.assertRaises(errors.DatabaseDoesNotExist, db.get_doc, 'doc1') + +    def test__ensure(self): +        db = http_database.HTTPDatabase(self.getURL('new')) +        db._ensure() +        self.assertIs(None, db.get_doc('doc1')) + +    def test__delete(self): +        self.request_state._create_database('db0') +        db = http_database.HTTPDatabase(self.getURL('db0')) +        db._delete() +        self.assertRaises(errors.DatabaseDoesNotExist, +                          self.request_state.check_database, 'db0') + +    def test_open_database_existing(self): +        self.request_state._create_database('db0') +        db = http_database.HTTPDatabase.open_database(self.getURL('db0'), +                                                      create=False) +        self.assertIs(None, db.get_doc('doc1')) + +    def test_open_database_non_existing(self): +        self.assertRaises(errors.DatabaseDoesNotExist, +                          http_database.HTTPDatabase.open_database, +                          self.getURL('not-there'), +                          create=False) + +    def test_open_database_create(self): +        db = http_database.HTTPDatabase.open_database(self.getURL('new'), +                                                      create=True) +        self.assertIs(None, db.get_doc('doc1')) + +    def test_delete_database_existing(self): +        self.request_state._create_database('db0') +        http_database.HTTPDatabase.delete_database(self.getURL('db0')) +        self.assertRaises(errors.DatabaseDoesNotExist, +                          self.request_state.check_database, 'db0') + +    def test_doc_ids_needing_quoting(self): +        db0 = self.request_state._create_database('db0') +        db = http_database.HTTPDatabase.open_database(self.getURL('db0'), +                                                      create=False) +        doc = Document('%fff', None, '{}') +        db.put_doc(doc) +        self.assertGetDoc(db0, '%fff', doc.rev, '{}', False) +        self.assertGetDoc(db, '%fff', doc.rev, '{}', False) diff --git a/common/src/leap/soledad/common/tests/u1db_tests/test_https.py b/common/src/leap/soledad/common/tests/u1db_tests/test_https.py new file mode 100644 index 00000000..c086fbc0 --- /dev/null +++ b/common/src/leap/soledad/common/tests/u1db_tests/test_https.py @@ -0,0 +1,124 @@ +"""Test support for client-side https support.""" + +import os +import ssl +import sys + +from paste import httpserver + +from u1db.remote import ( +    http_client, +    http_target, +) + +from leap import soledad +from leap.soledad.common.tests import u1db_tests as tests +from leap.soledad.common.tests.u1db_tests.test_remote_sync_target import ( +    make_oauth_http_app, +) + + +def https_server_def(): +    def make_server(host_port, application): +        from OpenSSL import SSL +        cert_file = os.path.join(os.path.dirname(__file__), 'testing-certs', +                                 'testing.cert') +        key_file = os.path.join(os.path.dirname(__file__), 'testing-certs', +                                'testing.key') +        ssl_context = SSL.Context(SSL.SSLv23_METHOD) +        ssl_context.use_privatekey_file(key_file) +        ssl_context.use_certificate_chain_file(cert_file) +        srv = httpserver.WSGIServerBase(application, host_port, +                                        httpserver.WSGIHandler, +                                        ssl_context=ssl_context +                                        ) + +        def shutdown_request(req): +            req.shutdown() +            srv.close_request(req) + +        srv.shutdown_request = shutdown_request +        application.base_url = "https://localhost:%s" % srv.server_address[1] +        return srv +    return make_server, "shutdown", "https" + + +def oauth_https_sync_target(test, host, path): +    _, port = test.server.server_address +    st = http_target.HTTPSyncTarget('https://%s:%d/~/%s' % (host, port, path)) +    st.set_oauth_credentials(tests.consumer1.key, tests.consumer1.secret, +                             tests.token1.key, tests.token1.secret) +    return st + + +class TestHttpSyncTargetHttpsSupport(tests.TestCaseWithServer): + +    scenarios = [ +        ('oauth_https', {'server_def': https_server_def, +                         'make_app_with_state': make_oauth_http_app, +                         'make_document_for_test': +                         tests.make_document_for_test, +                         'sync_target': oauth_https_sync_target +                         }), +    ] + +    def setUp(self): +        try: +            import OpenSSL  # noqa +        except ImportError: +            self.skipTest("Requires pyOpenSSL") +        self.cacert_pem = os.path.join(os.path.dirname(__file__), +                                       'testing-certs', 'cacert.pem') +        # The default u1db http_client class for doing HTTPS only does HTTPS +        # if the platform is linux. Because of this, soledad replaces that +        # class with one that will do HTTPS independent of the platform. In +        # order to maintain the compatibility with u1db default tests, we undo +        # that replacement here. +        http_client._VerifiedHTTPSConnection = \ +            soledad.client.old__VerifiedHTTPSConnection +        super(TestHttpSyncTargetHttpsSupport, self).setUp() + +    def getSyncTarget(self, host, path=None): +        if self.server is None: +            self.startServer() +        return self.sync_target(self, host, path) + +    def test_working(self): +        self.startServer() +        db = self.request_state._create_database('test') +        self.patch(http_client, 'CA_CERTS', self.cacert_pem) +        remote_target = self.getSyncTarget('localhost', 'test') +        remote_target.record_sync_info('other-id', 2, 'T-id') +        self.assertEqual( +            (2, 'T-id'), db._get_replica_gen_and_trans_id('other-id')) + +    def test_cannot_verify_cert(self): +        if not sys.platform.startswith('linux'): +            self.skipTest( +                "XXX certificate verification happens on linux only for now") +        self.startServer() +        # don't print expected traceback server-side +        self.server.handle_error = lambda req, cli_addr: None +        self.request_state._create_database('test') +        remote_target = self.getSyncTarget('localhost', 'test') +        try: +            remote_target.record_sync_info('other-id', 2, 'T-id') +        except ssl.SSLError, e: +            self.assertIn("certificate verify failed", str(e)) +        else: +            self.fail("certificate verification should have failed.") + +    def test_host_mismatch(self): +        if not sys.platform.startswith('linux'): +            self.skipTest( +                "XXX certificate verification happens on linux only for now") +        self.startServer() +        self.request_state._create_database('test') +        self.patch(http_client, 'CA_CERTS', self.cacert_pem) +        remote_target = self.getSyncTarget('127.0.0.1', 'test') +        self.assertRaises( +            http_client.CertificateError, remote_target.record_sync_info, +            'other-id', 2, 'T-id') + + +load_tests = tests.load_with_scenarios diff --git a/common/src/leap/soledad/common/tests/u1db_tests/test_open.py b/common/src/leap/soledad/common/tests/u1db_tests/test_open.py new file mode 100644 index 00000000..13425b4f --- /dev/null +++ b/common/src/leap/soledad/common/tests/u1db_tests/test_open.py @@ -0,0 +1,69 @@ +# Copyright 2011 Canonical Ltd. +# +# This file is part of u1db. +# +# u1db is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License version 3 +# as published by the Free Software Foundation. +# +# u1db is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with u1db.  If not, see <http://www.gnu.org/licenses/>. + +"""Test u1db.open""" + +import os + +from u1db import ( +    errors, +    open as u1db_open, +) +from leap.soledad.common.tests import u1db_tests as tests +from u1db.backends import sqlite_backend +from leap.soledad.common.tests.u1db_tests.test_backends import TestAlternativeDocument + + +class TestU1DBOpen(tests.TestCase): + +    def setUp(self): +        super(TestU1DBOpen, self).setUp() +        tmpdir = self.createTempDir() +        self.db_path = tmpdir + '/test.db' + +    def test_open_no_create(self): +        self.assertRaises(errors.DatabaseDoesNotExist, +                          u1db_open, self.db_path, create=False) +        self.assertFalse(os.path.exists(self.db_path)) + +    def test_open_create(self): +        db = u1db_open(self.db_path, create=True) +        self.addCleanup(db.close) +        self.assertTrue(os.path.exists(self.db_path)) +        self.assertIsInstance(db, sqlite_backend.SQLiteDatabase) + +    def test_open_with_factory(self): +        db = u1db_open(self.db_path, create=True, +                       document_factory=TestAlternativeDocument) +        self.addCleanup(db.close) +        self.assertEqual(TestAlternativeDocument, db._factory) + +    def test_open_existing(self): +        db = sqlite_backend.SQLitePartialExpandDatabase(self.db_path) +        self.addCleanup(db.close) +        doc = db.create_doc_from_json(tests.simple_doc) +        # Even though create=True, we shouldn't wipe the db +        db2 = u1db_open(self.db_path, create=True) +        self.addCleanup(db2.close) +        doc2 = db2.get_doc(doc.doc_id) +        self.assertEqual(doc, doc2) + +    def test_open_existing_no_create(self): +        db = sqlite_backend.SQLitePartialExpandDatabase(self.db_path) +        self.addCleanup(db.close) +        db2 = u1db_open(self.db_path, create=False) +        self.addCleanup(db2.close) +        self.assertIsInstance(db2, sqlite_backend.SQLitePartialExpandDatabase) diff --git a/common/src/leap/soledad/common/tests/u1db_tests/test_remote_sync_target.py b/common/src/leap/soledad/common/tests/u1db_tests/test_remote_sync_target.py new file mode 100644 index 00000000..3793e0df --- /dev/null +++ b/common/src/leap/soledad/common/tests/u1db_tests/test_remote_sync_target.py @@ -0,0 +1,317 @@ +# Copyright 2011-2012 Canonical Ltd. +# +# This file is part of u1db. +# +# u1db is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License version 3 +# as published by the Free Software Foundation. +# +# u1db is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with u1db.  If not, see <http://www.gnu.org/licenses/>. + +"""Tests for the remote sync targets""" + +import cStringIO + +from u1db import ( +    errors, +) + +from leap.soledad.common.tests import u1db_tests as tests + +from u1db.remote import ( +    http_app, +    http_target, +    oauth_middleware, +) + + +class TestHTTPSyncTargetBasics(tests.TestCase): + +    def test_parse_url(self): +        remote_target = http_target.HTTPSyncTarget('http://127.0.0.1:12345/') +        self.assertEqual('http', remote_target._url.scheme) +        self.assertEqual('127.0.0.1', remote_target._url.hostname) +        self.assertEqual(12345, remote_target._url.port) +        self.assertEqual('/', remote_target._url.path) + + +class TestParsingSyncStream(tests.TestCase): + +    def test_wrong_start(self): +        tgt = http_target.HTTPSyncTarget("http://foo/foo") + +        self.assertRaises(errors.BrokenSyncStream, +                          tgt._parse_sync_stream, "{}\r\n]", None) + +        self.assertRaises(errors.BrokenSyncStream, +                          tgt._parse_sync_stream, "\r\n{}\r\n]", None) + +        self.assertRaises(errors.BrokenSyncStream, +                          tgt._parse_sync_stream, "", None) + +    def test_wrong_end(self): +        tgt = http_target.HTTPSyncTarget("http://foo/foo") + +        self.assertRaises(errors.BrokenSyncStream, +                          tgt._parse_sync_stream, "[\r\n{}", None) + +        self.assertRaises(errors.BrokenSyncStream, +                          tgt._parse_sync_stream, "[\r\n", None) + +    def test_missing_comma(self): +        tgt = http_target.HTTPSyncTarget("http://foo/foo") + +        self.assertRaises(errors.BrokenSyncStream, +                          tgt._parse_sync_stream, +                          '[\r\n{}\r\n{"id": "i", "rev": "r", ' +                          '"content": "c", "gen": 3}\r\n]', None) + +    def test_no_entries(self): +        tgt = http_target.HTTPSyncTarget("http://foo/foo") + +        self.assertRaises(errors.BrokenSyncStream, +                          tgt._parse_sync_stream, "[\r\n]", None) + +    def test_extra_comma(self): +        tgt = http_target.HTTPSyncTarget("http://foo/foo") + +        self.assertRaises(errors.BrokenSyncStream, +                          tgt._parse_sync_stream, "[\r\n{},\r\n]", None) + +        self.assertRaises(errors.BrokenSyncStream, +                          tgt._parse_sync_stream, +                          '[\r\n{},\r\n{"id": "i", "rev": "r", ' +                          '"content": "{}", "gen": 3, "trans_id": "T-sid"}' +                          ',\r\n]', +                          lambda doc, gen, trans_id: None) + +    def test_error_in_stream(self): +        tgt = http_target.HTTPSyncTarget("http://foo/foo") + +        self.assertRaises(errors.Unavailable, +                          tgt._parse_sync_stream, +                          '[\r\n{"new_generation": 0},' +                          '\r\n{"error": "unavailable"}\r\n', None) + +        self.assertRaises(errors.Unavailable, +                          tgt._parse_sync_stream, +                          '[\r\n{"error": "unavailable"}\r\n', None) + +        self.assertRaises(errors.BrokenSyncStream, +                          tgt._parse_sync_stream, +                          '[\r\n{"error": "?"}\r\n', None) + + +def make_http_app(state): +    return http_app.HTTPApp(state) + + +def http_sync_target(test, path): +    return http_target.HTTPSyncTarget(test.getURL(path)) + + +def make_oauth_http_app(state): +    app = http_app.HTTPApp(state) +    application = oauth_middleware.OAuthMiddleware(app, None, prefix='/~/') +    application.get_oauth_data_store = lambda: tests.testingOAuthStore +    return application + + +def oauth_http_sync_target(test, path): +    st = http_sync_target(test, '~/' + path) +    st.set_oauth_credentials(tests.consumer1.key, tests.consumer1.secret, +                             tests.token1.key, tests.token1.secret) +    return st + + +class TestRemoteSyncTargets(tests.TestCaseWithServer): + +    scenarios = [ +        ('http', {'make_app_with_state': make_http_app, +                  'make_document_for_test': tests.make_document_for_test, +                  'sync_target': http_sync_target}), +        ('oauth_http', {'make_app_with_state': make_oauth_http_app, +                        'make_document_for_test': tests.make_document_for_test, +                        'sync_target': oauth_http_sync_target}), +    ] + +    def getSyncTarget(self, path=None): +        if self.server is None: +            self.startServer() +        return self.sync_target(self, path) + +    def test_get_sync_info(self): +        self.startServer() +        db = self.request_state._create_database('test') +        db._set_replica_gen_and_trans_id('other-id', 1, 'T-transid') +        remote_target = self.getSyncTarget('test') +        self.assertEqual(('test', 0, '', 1, 'T-transid'), +                         remote_target.get_sync_info('other-id')) + +    def test_record_sync_info(self): +        self.startServer() +        db = self.request_state._create_database('test') +        remote_target = self.getSyncTarget('test') +        remote_target.record_sync_info('other-id', 2, 'T-transid') +        self.assertEqual( +            (2, 'T-transid'), db._get_replica_gen_and_trans_id('other-id')) + +    def test_sync_exchange_send(self): +        self.startServer() +        db = self.request_state._create_database('test') +        remote_target = self.getSyncTarget('test') +        other_docs = [] + +        def receive_doc(doc): +            other_docs.append((doc.doc_id, doc.rev, doc.get_json())) + +        doc = self.make_document('doc-here', 'replica:1', '{"value": "here"}') +        new_gen, trans_id = remote_target.sync_exchange( +            [(doc, 10, 'T-sid')], 'replica', last_known_generation=0, +            last_known_trans_id=None, return_doc_cb=receive_doc) +        self.assertEqual(1, new_gen) +        self.assertGetDoc( +            db, 'doc-here', 'replica:1', '{"value": "here"}', False) + +    def test_sync_exchange_send_failure_and_retry_scenario(self): +        self.startServer() + +        def blackhole_getstderr(inst): +            return cStringIO.StringIO() + +        self.patch(self.server.RequestHandlerClass, 'get_stderr', +                   blackhole_getstderr) +        db = self.request_state._create_database('test') +        _put_doc_if_newer = db._put_doc_if_newer +        trigger_ids = ['doc-here2'] + +        def bomb_put_doc_if_newer(doc, save_conflict, +                                  replica_uid=None, replica_gen=None, +                                  replica_trans_id=None): +            if doc.doc_id in trigger_ids: +                raise Exception +            return _put_doc_if_newer(doc, save_conflict=save_conflict, +                                     replica_uid=replica_uid, +                                     replica_gen=replica_gen, +                                     replica_trans_id=replica_trans_id) +        self.patch(db, '_put_doc_if_newer', bomb_put_doc_if_newer) +        remote_target = self.getSyncTarget('test') +        other_changes = [] + +        def receive_doc(doc, gen, trans_id): +            other_changes.append( +                (doc.doc_id, doc.rev, doc.get_json(), gen, trans_id)) + +        doc1 = self.make_document('doc-here', 'replica:1', '{"value": "here"}') +        doc2 = self.make_document('doc-here2', 'replica:1', +                                  '{"value": "here2"}') +        self.assertRaises( +            errors.HTTPError, +            remote_target.sync_exchange, +            [(doc1, 10, 'T-sid'), (doc2, 11, 'T-sud')], +            'replica', last_known_generation=0, last_known_trans_id=None, +            return_doc_cb=receive_doc) +        self.assertGetDoc(db, 'doc-here', 'replica:1', '{"value": "here"}', +                          False) +        self.assertEqual( +            (10, 'T-sid'), db._get_replica_gen_and_trans_id('replica')) +        self.assertEqual([], other_changes) +        # retry +        trigger_ids = [] +        new_gen, trans_id = remote_target.sync_exchange( +            [(doc2, 11, 'T-sud')], 'replica', last_known_generation=0, +            last_known_trans_id=None, return_doc_cb=receive_doc) +        self.assertGetDoc(db, 'doc-here2', 'replica:1', '{"value": "here2"}', +                          False) +        self.assertEqual( +            (11, 'T-sud'), db._get_replica_gen_and_trans_id('replica')) +        self.assertEqual(2, new_gen) +        # bounced back to us +        self.assertEqual( +            ('doc-here', 'replica:1', '{"value": "here"}', 1), +            other_changes[0][:-1]) + +    def test_sync_exchange_in_stream_error(self): +        self.startServer() + +        def blackhole_getstderr(inst): +            return cStringIO.StringIO() + +        self.patch(self.server.RequestHandlerClass, 'get_stderr', +                   blackhole_getstderr) +        db = self.request_state._create_database('test') +        doc = db.create_doc_from_json('{"value": "there"}') + +        def bomb_get_docs(doc_ids, check_for_conflicts=None, +                          include_deleted=False): +            yield doc +            # delayed failure case +            raise errors.Unavailable + +        self.patch(db, 'get_docs', bomb_get_docs) +        remote_target = self.getSyncTarget('test') +        other_changes = [] + +        def receive_doc(doc, gen, trans_id): +            other_changes.append( +                (doc.doc_id, doc.rev, doc.get_json(), gen, trans_id)) + +        self.assertRaises( +            errors.Unavailable, remote_target.sync_exchange, [], 'replica', +            last_known_generation=0, last_known_trans_id=None, +            return_doc_cb=receive_doc) +        self.assertEqual( +            (doc.doc_id, doc.rev, '{"value": "there"}', 1), +            other_changes[0][:-1]) + +    def test_sync_exchange_receive(self): +        self.startServer() +        db = self.request_state._create_database('test') +        doc = db.create_doc_from_json('{"value": "there"}') +        remote_target = self.getSyncTarget('test') +        other_changes = [] + +        def receive_doc(doc, gen, trans_id): +            other_changes.append( +                (doc.doc_id, doc.rev, doc.get_json(), gen, trans_id)) + +        new_gen, trans_id = remote_target.sync_exchange( +            [], 'replica', last_known_generation=0, last_known_trans_id=None, +            return_doc_cb=receive_doc) +        self.assertEqual(1, new_gen) +        self.assertEqual( +            (doc.doc_id, doc.rev, '{"value": "there"}', 1), +            other_changes[0][:-1]) + +    def test_sync_exchange_send_ensure_callback(self): +        self.startServer() +        remote_target = self.getSyncTarget('test') +        other_docs = [] +        replica_uid_box = [] + +        def receive_doc(doc): +            other_docs.append((doc.doc_id, doc.rev, doc.get_json())) + +        def ensure_cb(replica_uid): +            replica_uid_box.append(replica_uid) + +        doc = self.make_document('doc-here', 'replica:1', '{"value": "here"}') +        new_gen, trans_id = remote_target.sync_exchange( +            [(doc, 10, 'T-sid')], 'replica', last_known_generation=0, +            last_known_trans_id=None, return_doc_cb=receive_doc, +            ensure_callback=ensure_cb) +        self.assertEqual(1, new_gen) +        db = self.request_state.open_database('test') +        self.assertEqual(1, len(replica_uid_box)) +        self.assertEqual(db._replica_uid, replica_uid_box[0]) +        self.assertGetDoc( +            db, 'doc-here', 'replica:1', '{"value": "here"}', False) + + +load_tests = tests.load_with_scenarios diff --git a/common/src/leap/soledad/common/tests/u1db_tests/test_sqlite_backend.py b/common/src/leap/soledad/common/tests/u1db_tests/test_sqlite_backend.py new file mode 100644 index 00000000..a53ea6cc --- /dev/null +++ b/common/src/leap/soledad/common/tests/u1db_tests/test_sqlite_backend.py @@ -0,0 +1,494 @@ +# Copyright 2011 Canonical Ltd. +# +# This file is part of u1db. +# +# u1db is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License version 3 +# as published by the Free Software Foundation. +# +# u1db is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with u1db.  If not, see <http://www.gnu.org/licenses/>. + +"""Test sqlite backend internals.""" + +import os +import time +import threading + +from pysqlcipher import dbapi2 + +from u1db import ( +    errors, +    query_parser, +) + +from leap.soledad.common.tests import u1db_tests as tests + +from u1db.backends import sqlite_backend +from leap.soledad.common.tests.u1db_tests.test_backends import TestAlternativeDocument + + +simple_doc = '{"key": "value"}' +nested_doc = '{"key": "value", "sub": {"doc": "underneath"}}' + + +class TestSQLiteDatabase(tests.TestCase): + +    def test_atomic_initialize(self): +        tmpdir = self.createTempDir() +        dbname = os.path.join(tmpdir, 'atomic.db') + +        t2 = None  # will be a thread + +        class SQLiteDatabaseTesting(sqlite_backend.SQLiteDatabase): +            _index_storage_value = "testing" + +            def __init__(self, dbname, ntry): +                self._try = ntry +                self._is_initialized_invocations = 0 +                super(SQLiteDatabaseTesting, self).__init__(dbname) + +            def _is_initialized(self, c): +                res = super(SQLiteDatabaseTesting, self)._is_initialized(c) +                if self._try == 1: +                    self._is_initialized_invocations += 1 +                    if self._is_initialized_invocations == 2: +                        t2.start() +                        # hard to do better and have a generic test +                        time.sleep(0.05) +                return res + +        outcome2 = [] + +        def second_try(): +            try: +                db2 = SQLiteDatabaseTesting(dbname, 2) +            except Exception, e: +                outcome2.append(e) +            else: +                outcome2.append(db2) + +        t2 = threading.Thread(target=second_try) +        db1 = SQLiteDatabaseTesting(dbname, 1) +        t2.join() + +        self.assertIsInstance(outcome2[0], SQLiteDatabaseTesting) +        db2 = outcome2[0] +        self.assertTrue(db2._is_initialized(db1._get_sqlite_handle().cursor())) + + +class TestSQLitePartialExpandDatabase(tests.TestCase): + +    def setUp(self): +        super(TestSQLitePartialExpandDatabase, self).setUp() +        self.db = sqlite_backend.SQLitePartialExpandDatabase(':memory:') +        self.db._set_replica_uid('test') + +    def test_create_database(self): +        raw_db = self.db._get_sqlite_handle() +        self.assertNotEqual(None, raw_db) + +    def test_default_replica_uid(self): +        self.db = sqlite_backend.SQLitePartialExpandDatabase(':memory:') +        self.assertIsNot(None, self.db._replica_uid) +        self.assertEqual(32, len(self.db._replica_uid)) +        int(self.db._replica_uid, 16) + +    def test__close_sqlite_handle(self): +        raw_db = self.db._get_sqlite_handle() +        self.db._close_sqlite_handle() +        self.assertRaises(dbapi2.ProgrammingError, +                          raw_db.cursor) + +    def test_create_database_initializes_schema(self): +        raw_db = self.db._get_sqlite_handle() +        c = raw_db.cursor() +        c.execute("SELECT * FROM u1db_config") +        config = dict([(r[0], r[1]) for r in c.fetchall()]) +        self.assertEqual({'sql_schema': '0', 'replica_uid': 'test', +                          'index_storage': 'expand referenced'}, config) + +        # These tables must exist, though we don't care what is in them yet +        c.execute("SELECT * FROM transaction_log") +        c.execute("SELECT * FROM document") +        c.execute("SELECT * FROM document_fields") +        c.execute("SELECT * FROM sync_log") +        c.execute("SELECT * FROM conflicts") +        c.execute("SELECT * FROM index_definitions") + +    def test__parse_index(self): +        self.db = sqlite_backend.SQLitePartialExpandDatabase(':memory:') +        g = self.db._parse_index_definition('fieldname') +        self.assertIsInstance(g, query_parser.ExtractField) +        self.assertEqual(['fieldname'], g.field) + +    def test__update_indexes(self): +        self.db = sqlite_backend.SQLitePartialExpandDatabase(':memory:') +        g = self.db._parse_index_definition('fieldname') +        c = self.db._get_sqlite_handle().cursor() +        self.db._update_indexes('doc-id', {'fieldname': 'val'}, +                                [('fieldname', g)], c) +        c.execute('SELECT doc_id, field_name, value FROM document_fields') +        self.assertEqual([('doc-id', 'fieldname', 'val')], +                         c.fetchall()) + +    def test__set_replica_uid(self): +        # Start from scratch, so that replica_uid isn't set. +        self.db = sqlite_backend.SQLitePartialExpandDatabase(':memory:') +        self.assertIsNot(None, self.db._real_replica_uid) +        self.assertIsNot(None, self.db._replica_uid) +        self.db._set_replica_uid('foo') +        c = self.db._get_sqlite_handle().cursor() +        c.execute("SELECT value FROM u1db_config WHERE name='replica_uid'") +        self.assertEqual(('foo',), c.fetchone()) +        self.assertEqual('foo', self.db._real_replica_uid) +        self.assertEqual('foo', self.db._replica_uid) +        self.db._close_sqlite_handle() +        self.assertEqual('foo', self.db._replica_uid) + +    def test__get_generation(self): +        self.assertEqual(0, self.db._get_generation()) + +    def test__get_generation_info(self): +        self.assertEqual((0, ''), self.db._get_generation_info()) + +    def test_create_index(self): +        self.db.create_index('test-idx', "key") +        self.assertEqual([('test-idx', ["key"])], self.db.list_indexes()) + +    def test_create_index_multiple_fields(self): +        self.db.create_index('test-idx', "key", "key2") +        self.assertEqual([('test-idx', ["key", "key2"])], +                         self.db.list_indexes()) + +    def test__get_index_definition(self): +        self.db.create_index('test-idx', "key", "key2") +        # TODO: How would you test that an index is getting used for an SQL +        #       request? +        self.assertEqual(["key", "key2"], +                         self.db._get_index_definition('test-idx')) + +    def test_list_index_mixed(self): +        # Make sure that we properly order the output +        c = self.db._get_sqlite_handle().cursor() +        # We intentionally insert the data in weird ordering, to make sure the +        # query still gets it back correctly. +        c.executemany("INSERT INTO index_definitions VALUES (?, ?, ?)", +                      [('idx-1', 0, 'key10'), +                       ('idx-2', 2, 'key22'), +                       ('idx-1', 1, 'key11'), +                       ('idx-2', 0, 'key20'), +                       ('idx-2', 1, 'key21')]) +        self.assertEqual([('idx-1', ['key10', 'key11']), +                          ('idx-2', ['key20', 'key21', 'key22'])], +                         self.db.list_indexes()) + +    def test_no_indexes_no_document_fields(self): +        self.db.create_doc_from_json( +            '{"key1": "val1", "key2": "val2"}') +        c = self.db._get_sqlite_handle().cursor() +        c.execute("SELECT doc_id, field_name, value FROM document_fields" +                  " ORDER BY doc_id, field_name, value") +        self.assertEqual([], c.fetchall()) + +    def test_create_extracts_fields(self): +        doc1 = self.db.create_doc_from_json('{"key1": "val1", "key2": "val2"}') +        doc2 = self.db.create_doc_from_json('{"key1": "valx", "key2": "valy"}') +        c = self.db._get_sqlite_handle().cursor() +        c.execute("SELECT doc_id, field_name, value FROM document_fields" +                  " ORDER BY doc_id, field_name, value") +        self.assertEqual([], c.fetchall()) +        self.db.create_index('test', 'key1', 'key2') +        c.execute("SELECT doc_id, field_name, value FROM document_fields" +                  " ORDER BY doc_id, field_name, value") +        self.assertEqual(sorted( +            [(doc1.doc_id, "key1", "val1"), +             (doc1.doc_id, "key2", "val2"), +             (doc2.doc_id, "key1", "valx"), +             (doc2.doc_id, "key2", "valy"), ]), sorted(c.fetchall())) + +    def test_put_updates_fields(self): +        self.db.create_index('test', 'key1', 'key2') +        doc1 = self.db.create_doc_from_json( +            '{"key1": "val1", "key2": "val2"}') +        doc1.content = {"key1": "val1", "key2": "valy"} +        self.db.put_doc(doc1) +        c = self.db._get_sqlite_handle().cursor() +        c.execute("SELECT doc_id, field_name, value FROM document_fields" +                  " ORDER BY doc_id, field_name, value") +        self.assertEqual([(doc1.doc_id, "key1", "val1"), +                          (doc1.doc_id, "key2", "valy"), ], c.fetchall()) + +    def test_put_updates_nested_fields(self): +        self.db.create_index('test', 'key', 'sub.doc') +        doc1 = self.db.create_doc_from_json(nested_doc) +        c = self.db._get_sqlite_handle().cursor() +        c.execute("SELECT doc_id, field_name, value FROM document_fields" +                  " ORDER BY doc_id, field_name, value") +        self.assertEqual([(doc1.doc_id, "key", "value"), +                          (doc1.doc_id, "sub.doc", "underneath"), ], +                         c.fetchall()) + +    def test__ensure_schema_rollback(self): +        temp_dir = self.createTempDir(prefix='u1db-test-') +        path = temp_dir + '/rollback.db' + +        class SQLitePartialExpandDbTesting( +                sqlite_backend.SQLitePartialExpandDatabase): + +            def _set_replica_uid_in_transaction(self, uid): +                super(SQLitePartialExpandDbTesting, +                      self)._set_replica_uid_in_transaction(uid) +                if fail: +                    raise Exception() + +        db = SQLitePartialExpandDbTesting.__new__(SQLitePartialExpandDbTesting) +        db._db_handle = dbapi2.connect(path)  # db is there but not yet init-ed +        fail = True +        self.assertRaises(Exception, db._ensure_schema) +        fail = False +        db._initialize(db._db_handle.cursor()) + +    def test__open_database(self): +        temp_dir = self.createTempDir(prefix='u1db-test-') +        path = temp_dir + '/test.sqlite' +        sqlite_backend.SQLitePartialExpandDatabase(path) +        db2 = sqlite_backend.SQLiteDatabase._open_database(path) +        self.assertIsInstance(db2, sqlite_backend.SQLitePartialExpandDatabase) + +    def test__open_database_with_factory(self): +        temp_dir = self.createTempDir(prefix='u1db-test-') +        path = temp_dir + '/test.sqlite' +        sqlite_backend.SQLitePartialExpandDatabase(path) +        db2 = sqlite_backend.SQLiteDatabase._open_database( +            path, document_factory=TestAlternativeDocument) +        self.assertEqual(TestAlternativeDocument, db2._factory) + +    def test__open_database_non_existent(self): +        temp_dir = self.createTempDir(prefix='u1db-test-') +        path = temp_dir + '/non-existent.sqlite' +        self.assertRaises(errors.DatabaseDoesNotExist, +                          sqlite_backend.SQLiteDatabase._open_database, path) + +    def test__open_database_during_init(self): +        temp_dir = self.createTempDir(prefix='u1db-test-') +        path = temp_dir + '/initialised.db' +        db = sqlite_backend.SQLitePartialExpandDatabase.__new__( +            sqlite_backend.SQLitePartialExpandDatabase) +        db._db_handle = dbapi2.connect(path)  # db is there but not yet init-ed +        self.addCleanup(db.close) +        observed = [] + +        class SQLiteDatabaseTesting(sqlite_backend.SQLiteDatabase): +            WAIT_FOR_PARALLEL_INIT_HALF_INTERVAL = 0.1 + +            @classmethod +            def _which_index_storage(cls, c): +                res = super(SQLiteDatabaseTesting, cls)._which_index_storage(c) +                db._ensure_schema()  # init db +                observed.append(res[0]) +                return res + +        db2 = SQLiteDatabaseTesting._open_database(path) +        self.addCleanup(db2.close) +        self.assertIsInstance(db2, sqlite_backend.SQLitePartialExpandDatabase) +        self.assertEqual( +            [None, +             sqlite_backend.SQLitePartialExpandDatabase._index_storage_value], +            observed) + +    def test__open_database_invalid(self): +        class SQLiteDatabaseTesting(sqlite_backend.SQLiteDatabase): +            WAIT_FOR_PARALLEL_INIT_HALF_INTERVAL = 0.1 +        temp_dir = self.createTempDir(prefix='u1db-test-') +        path1 = temp_dir + '/invalid1.db' +        with open(path1, 'wb') as f: +            f.write("") +        self.assertRaises(dbapi2.OperationalError, +                          SQLiteDatabaseTesting._open_database, path1) +        with open(path1, 'wb') as f: +            f.write("invalid") +        self.assertRaises(dbapi2.DatabaseError, +                          SQLiteDatabaseTesting._open_database, path1) + +    def test_open_database_existing(self): +        temp_dir = self.createTempDir(prefix='u1db-test-') +        path = temp_dir + '/existing.sqlite' +        sqlite_backend.SQLitePartialExpandDatabase(path) +        db2 = sqlite_backend.SQLiteDatabase.open_database(path, create=False) +        self.assertIsInstance(db2, sqlite_backend.SQLitePartialExpandDatabase) + +    def test_open_database_with_factory(self): +        temp_dir = self.createTempDir(prefix='u1db-test-') +        path = temp_dir + '/existing.sqlite' +        sqlite_backend.SQLitePartialExpandDatabase(path) +        db2 = sqlite_backend.SQLiteDatabase.open_database( +            path, create=False, document_factory=TestAlternativeDocument) +        self.assertEqual(TestAlternativeDocument, db2._factory) + +    def test_open_database_create(self): +        temp_dir = self.createTempDir(prefix='u1db-test-') +        path = temp_dir + '/new.sqlite' +        sqlite_backend.SQLiteDatabase.open_database(path, create=True) +        db2 = sqlite_backend.SQLiteDatabase.open_database(path, create=False) +        self.assertIsInstance(db2, sqlite_backend.SQLitePartialExpandDatabase) + +    def test_open_database_non_existent(self): +        temp_dir = self.createTempDir(prefix='u1db-test-') +        path = temp_dir + '/non-existent.sqlite' +        self.assertRaises(errors.DatabaseDoesNotExist, +                          sqlite_backend.SQLiteDatabase.open_database, path, +                          create=False) + +    def test_delete_database_existent(self): +        temp_dir = self.createTempDir(prefix='u1db-test-') +        path = temp_dir + '/new.sqlite' +        db = sqlite_backend.SQLiteDatabase.open_database(path, create=True) +        db.close() +        sqlite_backend.SQLiteDatabase.delete_database(path) +        self.assertRaises(errors.DatabaseDoesNotExist, +                          sqlite_backend.SQLiteDatabase.open_database, path, +                          create=False) + +    def test_delete_database_nonexistent(self): +        temp_dir = self.createTempDir(prefix='u1db-test-') +        path = temp_dir + '/non-existent.sqlite' +        self.assertRaises(errors.DatabaseDoesNotExist, +                          sqlite_backend.SQLiteDatabase.delete_database, path) + +    def test__get_indexed_fields(self): +        self.db.create_index('idx1', 'a', 'b') +        self.assertEqual(set(['a', 'b']), self.db._get_indexed_fields()) +        self.db.create_index('idx2', 'b', 'c') +        self.assertEqual(set(['a', 'b', 'c']), self.db._get_indexed_fields()) + +    def test_indexed_fields_expanded(self): +        self.db.create_index('idx1', 'key1') +        doc1 = self.db.create_doc_from_json('{"key1": "val1", "key2": "val2"}') +        self.assertEqual(set(['key1']), self.db._get_indexed_fields()) +        c = self.db._get_sqlite_handle().cursor() +        c.execute("SELECT doc_id, field_name, value FROM document_fields" +                  " ORDER BY doc_id, field_name, value") +        self.assertEqual([(doc1.doc_id, 'key1', 'val1')], c.fetchall()) + +    def test_create_index_updates_fields(self): +        doc1 = self.db.create_doc_from_json('{"key1": "val1", "key2": "val2"}') +        self.db.create_index('idx1', 'key1') +        self.assertEqual(set(['key1']), self.db._get_indexed_fields()) +        c = self.db._get_sqlite_handle().cursor() +        c.execute("SELECT doc_id, field_name, value FROM document_fields" +                  " ORDER BY doc_id, field_name, value") +        self.assertEqual([(doc1.doc_id, 'key1', 'val1')], c.fetchall()) + +    def assertFormatQueryEquals(self, exp_statement, exp_args, definition, +                                values): +        statement, args = self.db._format_query(definition, values) +        self.assertEqual(exp_statement, statement) +        self.assertEqual(exp_args, args) + +    def test__format_query(self): +        self.assertFormatQueryEquals( +            "SELECT d.doc_id, d.doc_rev, d.content, count(c.doc_rev) FROM " +            "document d, document_fields d0 LEFT OUTER JOIN conflicts c ON " +            "c.doc_id = d.doc_id WHERE d.doc_id = d0.doc_id AND d0.field_name " +            "= ? AND d0.value = ? GROUP BY d.doc_id, d.doc_rev, d.content " +            "ORDER BY d0.value;", ["key1", "a"], +            ["key1"], ["a"]) + +    def test__format_query2(self): +        self.assertFormatQueryEquals( +            'SELECT d.doc_id, d.doc_rev, d.content, count(c.doc_rev) FROM ' +            'document d, document_fields d0, document_fields d1, ' +            'document_fields d2 LEFT OUTER JOIN conflicts c ON c.doc_id = ' +            'd.doc_id WHERE d.doc_id = d0.doc_id AND d0.field_name = ? AND ' +            'd0.value = ? AND d.doc_id = d1.doc_id AND d1.field_name = ? AND ' +            'd1.value = ? AND d.doc_id = d2.doc_id AND d2.field_name = ? AND ' +            'd2.value = ? GROUP BY d.doc_id, d.doc_rev, d.content ORDER BY ' +            'd0.value, d1.value, d2.value;', +            ["key1", "a", "key2", "b", "key3", "c"], +            ["key1", "key2", "key3"], ["a", "b", "c"]) + +    def test__format_query_wildcard(self): +        self.assertFormatQueryEquals( +            'SELECT d.doc_id, d.doc_rev, d.content, count(c.doc_rev) FROM ' +            'document d, document_fields d0, document_fields d1, ' +            'document_fields d2 LEFT OUTER JOIN conflicts c ON c.doc_id = ' +            'd.doc_id WHERE d.doc_id = d0.doc_id AND d0.field_name = ? AND ' +            'd0.value = ? AND d.doc_id = d1.doc_id AND d1.field_name = ? AND ' +            'd1.value GLOB ? AND d.doc_id = d2.doc_id AND d2.field_name = ? ' +            'AND d2.value NOT NULL GROUP BY d.doc_id, d.doc_rev, d.content ' +            'ORDER BY d0.value, d1.value, d2.value;', +            ["key1", "a", "key2", "b*", "key3"], ["key1", "key2", "key3"], +            ["a", "b*", "*"]) + +    def assertFormatRangeQueryEquals(self, exp_statement, exp_args, definition, +                                     start_value, end_value): +        statement, args = self.db._format_range_query( +            definition, start_value, end_value) +        self.assertEqual(exp_statement, statement) +        self.assertEqual(exp_args, args) + +    def test__format_range_query(self): +        self.assertFormatRangeQueryEquals( +            'SELECT d.doc_id, d.doc_rev, d.content, count(c.doc_rev) FROM ' +            'document d, document_fields d0, document_fields d1, ' +            'document_fields d2 LEFT OUTER JOIN conflicts c ON c.doc_id = ' +            'd.doc_id WHERE d.doc_id = d0.doc_id AND d0.field_name = ? AND ' +            'd0.value >= ? AND d.doc_id = d1.doc_id AND d1.field_name = ? AND ' +            'd1.value >= ? AND d.doc_id = d2.doc_id AND d2.field_name = ? AND ' +            'd2.value >= ? AND d.doc_id = d0.doc_id AND d0.field_name = ? AND ' +            'd0.value <= ? AND d.doc_id = d1.doc_id AND d1.field_name = ? AND ' +            'd1.value <= ? AND d.doc_id = d2.doc_id AND d2.field_name = ? AND ' +            'd2.value <= ? GROUP BY d.doc_id, d.doc_rev, d.content ORDER BY ' +            'd0.value, d1.value, d2.value;', +            ['key1', 'a', 'key2', 'b', 'key3', 'c', 'key1', 'p', 'key2', 'q', +             'key3', 'r'], +            ["key1", "key2", "key3"], ["a", "b", "c"], ["p", "q", "r"]) + +    def test__format_range_query_no_start(self): +        self.assertFormatRangeQueryEquals( +            'SELECT d.doc_id, d.doc_rev, d.content, count(c.doc_rev) FROM ' +            'document d, document_fields d0, document_fields d1, ' +            'document_fields d2 LEFT OUTER JOIN conflicts c ON c.doc_id = ' +            'd.doc_id WHERE d.doc_id = d0.doc_id AND d0.field_name = ? AND ' +            'd0.value <= ? AND d.doc_id = d1.doc_id AND d1.field_name = ? AND ' +            'd1.value <= ? AND d.doc_id = d2.doc_id AND d2.field_name = ? AND ' +            'd2.value <= ? GROUP BY d.doc_id, d.doc_rev, d.content ORDER BY ' +            'd0.value, d1.value, d2.value;', +            ['key1', 'a', 'key2', 'b', 'key3', 'c'], +            ["key1", "key2", "key3"], None, ["a", "b", "c"]) + +    def test__format_range_query_no_end(self): +        self.assertFormatRangeQueryEquals( +            'SELECT d.doc_id, d.doc_rev, d.content, count(c.doc_rev) FROM ' +            'document d, document_fields d0, document_fields d1, ' +            'document_fields d2 LEFT OUTER JOIN conflicts c ON c.doc_id = ' +            'd.doc_id WHERE d.doc_id = d0.doc_id AND d0.field_name = ? AND ' +            'd0.value >= ? AND d.doc_id = d1.doc_id AND d1.field_name = ? AND ' +            'd1.value >= ? AND d.doc_id = d2.doc_id AND d2.field_name = ? AND ' +            'd2.value >= ? GROUP BY d.doc_id, d.doc_rev, d.content ORDER BY ' +            'd0.value, d1.value, d2.value;', +            ['key1', 'a', 'key2', 'b', 'key3', 'c'], +            ["key1", "key2", "key3"], ["a", "b", "c"], None) + +    def test__format_range_query_wildcard(self): +        self.assertFormatRangeQueryEquals( +            'SELECT d.doc_id, d.doc_rev, d.content, count(c.doc_rev) FROM ' +            'document d, document_fields d0, document_fields d1, ' +            'document_fields d2 LEFT OUTER JOIN conflicts c ON c.doc_id = ' +            'd.doc_id WHERE d.doc_id = d0.doc_id AND d0.field_name = ? AND ' +            'd0.value >= ? AND d.doc_id = d1.doc_id AND d1.field_name = ? AND ' +            'd1.value >= ? AND d.doc_id = d2.doc_id AND d2.field_name = ? AND ' +            'd2.value NOT NULL AND d.doc_id = d0.doc_id AND d0.field_name = ? ' +            'AND d0.value <= ? AND d.doc_id = d1.doc_id AND d1.field_name = ? ' +            'AND (d1.value < ? OR d1.value GLOB ?) AND d.doc_id = d2.doc_id ' +            'AND d2.field_name = ? AND d2.value NOT NULL GROUP BY d.doc_id, ' +            'd.doc_rev, d.content ORDER BY d0.value, d1.value, d2.value;', +            ['key1', 'a', 'key2', 'b', 'key3', 'key1', 'p', 'key2', 'q', 'q*', +             'key3'], +            ["key1", "key2", "key3"], ["a", "b*", "*"], ["p", "q*", "*"]) diff --git a/common/src/leap/soledad/common/tests/u1db_tests/test_sync.py b/common/src/leap/soledad/common/tests/u1db_tests/test_sync.py new file mode 100644 index 00000000..5346d540 --- /dev/null +++ b/common/src/leap/soledad/common/tests/u1db_tests/test_sync.py @@ -0,0 +1,1246 @@ +# Copyright 2011-2012 Canonical Ltd. +# +# This file is part of u1db. +# +# u1db is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License version 3 +# as published by the Free Software Foundation. +# +# u1db is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with u1db.  If not, see <http://www.gnu.org/licenses/>. + +"""The Synchronization class for U1DB.""" + +import os +from wsgiref import simple_server + +from u1db import ( +    errors, +    sync, +    vectorclock, +    SyncTarget, +) + +from leap.soledad.common.tests import u1db_tests as tests + +from u1db.backends import ( +    inmemory, +) +from u1db.remote import ( +    http_target, +) + +from leap.soledad.common.tests.u1db_tests.test_remote_sync_target import ( +    make_http_app, +    make_oauth_http_app, +) + +simple_doc = tests.simple_doc +nested_doc = tests.nested_doc + + +def _make_local_db_and_target(test): +    db = test.create_database('test') +    st = db.get_sync_target() +    return db, st + + +def _make_local_db_and_http_target(test, path='test'): +    test.startServer() +    db = test.request_state._create_database(os.path.basename(path)) +    st = http_target.HTTPSyncTarget.connect(test.getURL(path)) +    return db, st + + +def _make_local_db_and_oauth_http_target(test): +    db, st = _make_local_db_and_http_target(test, '~/test') +    st.set_oauth_credentials(tests.consumer1.key, tests.consumer1.secret, +                             tests.token1.key, tests.token1.secret) +    return db, st + + +target_scenarios = [ +    ('local', {'create_db_and_target': _make_local_db_and_target}), +    ('http', {'create_db_and_target': _make_local_db_and_http_target, +              'make_app_with_state': make_http_app}), +    ('oauth_http', {'create_db_and_target': +                    _make_local_db_and_oauth_http_target, +                    'make_app_with_state': make_oauth_http_app}), +] + + +class DatabaseSyncTargetTests(tests.DatabaseBaseTests, +                              tests.TestCaseWithServer): + +    scenarios = (tests.multiply_scenarios(tests.DatabaseBaseTests.scenarios, +                                          target_scenarios)) +                 #+ c_db_scenarios) +    # whitebox true means self.db is the actual local db object +    # against which the sync is performed +    whitebox = True + +    def setUp(self): +        tests.DatabaseBaseTests.setUp(self) +        tests.TestCaseWithServer.setUp(self) +        self.db, self.st = self.create_db_and_target(self) +        self.other_changes = [] + +    def tearDown(self): +        # We delete them explicitly, so that connections are cleanly closed +        del self.st +        self.db.close() +        del self.db +        super(DatabaseSyncTargetTests, self).tearDown() + +    def create_db_and_target(self, *args): +        return _make_local_db_and_target(*args) + +    def receive_doc(self, doc, gen, trans_id): +        self.other_changes.append( +            (doc.doc_id, doc.rev, doc.get_json(), gen, trans_id)) + +    def set_trace_hook(self, callback, shallow=False): +        setter = (self.st._set_trace_hook if not shallow else +                  self.st._set_trace_hook_shallow) +        try: +            setter(callback) +        except NotImplementedError: +            self.skipTest("%s does not implement _set_trace_hook" +                          % (self.st.__class__.__name__,)) + +    def test_get_sync_target(self): +        self.assertIsNot(None, self.st) + +    def test_get_sync_info(self): +        self.assertEqual( +            ('test', 0, '', 0, ''), self.st.get_sync_info('other')) + +    def test_create_doc_updates_sync_info(self): +        self.assertEqual( +            ('test', 0, '', 0, ''), self.st.get_sync_info('other')) +        self.db.create_doc_from_json(simple_doc) +        self.assertEqual(1, self.st.get_sync_info('other')[1]) + +    def test_record_sync_info(self): +        self.st.record_sync_info('replica', 10, 'T-transid') +        self.assertEqual( +            ('test', 0, '', 10, 'T-transid'), self.st.get_sync_info('replica')) + +    def test_sync_exchange(self): +        docs_by_gen = [ +            (self.make_document('doc-id', 'replica:1', simple_doc), 10, +             'T-sid')] +        new_gen, trans_id = self.st.sync_exchange( +            docs_by_gen, 'replica', last_known_generation=0, +            last_known_trans_id=None, return_doc_cb=self.receive_doc) +        self.assertGetDoc(self.db, 'doc-id', 'replica:1', simple_doc, False) +        self.assertTransactionLog(['doc-id'], self.db) +        last_trans_id = self.getLastTransId(self.db) +        self.assertEqual(([], 1, last_trans_id), +                         (self.other_changes, new_gen, last_trans_id)) +        self.assertEqual(10, self.st.get_sync_info('replica')[3]) + +    def test_sync_exchange_deleted(self): +        doc = self.db.create_doc_from_json('{}') +        edit_rev = 'replica:1|' + doc.rev +        docs_by_gen = [ +            (self.make_document(doc.doc_id, edit_rev, None), 10, 'T-sid')] +        new_gen, trans_id = self.st.sync_exchange( +            docs_by_gen, 'replica', last_known_generation=0, +            last_known_trans_id=None, return_doc_cb=self.receive_doc) +        self.assertGetDocIncludeDeleted( +            self.db, doc.doc_id, edit_rev, None, False) +        self.assertTransactionLog([doc.doc_id, doc.doc_id], self.db) +        last_trans_id = self.getLastTransId(self.db) +        self.assertEqual(([], 2, last_trans_id), +                         (self.other_changes, new_gen, trans_id)) +        self.assertEqual(10, self.st.get_sync_info('replica')[3]) + +    def test_sync_exchange_push_many(self): +        docs_by_gen = [ +            (self.make_document('doc-id', 'replica:1', simple_doc), 10, 'T-1'), +            (self.make_document('doc-id2', 'replica:1', nested_doc), 11, +             'T-2')] +        new_gen, trans_id = self.st.sync_exchange( +            docs_by_gen, 'replica', last_known_generation=0, +            last_known_trans_id=None, return_doc_cb=self.receive_doc) +        self.assertGetDoc(self.db, 'doc-id', 'replica:1', simple_doc, False) +        self.assertGetDoc(self.db, 'doc-id2', 'replica:1', nested_doc, False) +        self.assertTransactionLog(['doc-id', 'doc-id2'], self.db) +        last_trans_id = self.getLastTransId(self.db) +        self.assertEqual(([], 2, last_trans_id), +                         (self.other_changes, new_gen, trans_id)) +        self.assertEqual(11, self.st.get_sync_info('replica')[3]) + +    def test_sync_exchange_refuses_conflicts(self): +        doc = self.db.create_doc_from_json(simple_doc) +        self.assertTransactionLog([doc.doc_id], self.db) +        new_doc = '{"key": "altval"}' +        docs_by_gen = [ +            (self.make_document(doc.doc_id, 'replica:1', new_doc), 10, +             'T-sid')] +        new_gen, _ = self.st.sync_exchange( +            docs_by_gen, 'replica', last_known_generation=0, +            last_known_trans_id=None, return_doc_cb=self.receive_doc) +        self.assertTransactionLog([doc.doc_id], self.db) +        self.assertEqual( +            (doc.doc_id, doc.rev, simple_doc, 1), self.other_changes[0][:-1]) +        self.assertEqual(1, new_gen) +        if self.whitebox: +            self.assertEqual(self.db._last_exchange_log['return'], +                             {'last_gen': 1, 'docs': [(doc.doc_id, doc.rev)]}) + +    def test_sync_exchange_ignores_convergence(self): +        doc = self.db.create_doc_from_json(simple_doc) +        self.assertTransactionLog([doc.doc_id], self.db) +        gen, txid = self.db._get_generation_info() +        docs_by_gen = [ +            (self.make_document(doc.doc_id, doc.rev, simple_doc), 10, 'T-sid')] +        new_gen, _ = self.st.sync_exchange( +            docs_by_gen, 'replica', last_known_generation=gen, +            last_known_trans_id=txid, return_doc_cb=self.receive_doc) +        self.assertTransactionLog([doc.doc_id], self.db) +        self.assertEqual(([], 1), (self.other_changes, new_gen)) + +    def test_sync_exchange_returns_new_docs(self): +        doc = self.db.create_doc_from_json(simple_doc) +        self.assertTransactionLog([doc.doc_id], self.db) +        new_gen, _ = self.st.sync_exchange( +            [], 'other-replica', last_known_generation=0, +            last_known_trans_id=None, return_doc_cb=self.receive_doc) +        self.assertTransactionLog([doc.doc_id], self.db) +        self.assertEqual( +            (doc.doc_id, doc.rev, simple_doc, 1), self.other_changes[0][:-1]) +        self.assertEqual(1, new_gen) +        if self.whitebox: +            self.assertEqual(self.db._last_exchange_log['return'], +                             {'last_gen': 1, 'docs': [(doc.doc_id, doc.rev)]}) + +    def test_sync_exchange_returns_deleted_docs(self): +        doc = self.db.create_doc_from_json(simple_doc) +        self.db.delete_doc(doc) +        self.assertTransactionLog([doc.doc_id, doc.doc_id], self.db) +        new_gen, _ = self.st.sync_exchange( +            [], 'other-replica', last_known_generation=0, +            last_known_trans_id=None, return_doc_cb=self.receive_doc) +        self.assertTransactionLog([doc.doc_id, doc.doc_id], self.db) +        self.assertEqual( +            (doc.doc_id, doc.rev, None, 2), self.other_changes[0][:-1]) +        self.assertEqual(2, new_gen) +        if self.whitebox: +            self.assertEqual(self.db._last_exchange_log['return'], +                             {'last_gen': 2, 'docs': [(doc.doc_id, doc.rev)]}) + +    def test_sync_exchange_returns_many_new_docs(self): +        doc = self.db.create_doc_from_json(simple_doc) +        doc2 = self.db.create_doc_from_json(nested_doc) +        self.assertTransactionLog([doc.doc_id, doc2.doc_id], self.db) +        new_gen, _ = self.st.sync_exchange( +            [], 'other-replica', last_known_generation=0, +            last_known_trans_id=None, return_doc_cb=self.receive_doc) +        self.assertTransactionLog([doc.doc_id, doc2.doc_id], self.db) +        self.assertEqual(2, new_gen) +        self.assertEqual( +            [(doc.doc_id, doc.rev, simple_doc, 1), +             (doc2.doc_id, doc2.rev, nested_doc, 2)], +            [c[:-1] for c in self.other_changes]) +        if self.whitebox: +            self.assertEqual( +                self.db._last_exchange_log['return'], +                {'last_gen': 2, 'docs': +                 [(doc.doc_id, doc.rev), (doc2.doc_id, doc2.rev)]}) + +    def test_sync_exchange_getting_newer_docs(self): +        doc = self.db.create_doc_from_json(simple_doc) +        self.assertTransactionLog([doc.doc_id], self.db) +        new_doc = '{"key": "altval"}' +        docs_by_gen = [ +            (self.make_document(doc.doc_id, 'test:1|z:2', new_doc), 10, +             'T-sid')] +        new_gen, _ = self.st.sync_exchange( +            docs_by_gen, 'other-replica', last_known_generation=0, +            last_known_trans_id=None, return_doc_cb=self.receive_doc) +        self.assertTransactionLog([doc.doc_id, doc.doc_id], self.db) +        self.assertEqual(([], 2), (self.other_changes, new_gen)) + +    def test_sync_exchange_with_concurrent_updates_of_synced_doc(self): +        expected = [] + +        def before_whatschanged_cb(state): +            if state != 'before whats_changed': +                return +            cont = '{"key": "cuncurrent"}' +            conc_rev = self.db.put_doc( +                self.make_document(doc.doc_id, 'test:1|z:2', cont)) +            expected.append((doc.doc_id, conc_rev, cont, 3)) + +        self.set_trace_hook(before_whatschanged_cb) +        doc = self.db.create_doc_from_json(simple_doc) +        self.assertTransactionLog([doc.doc_id], self.db) +        new_doc = '{"key": "altval"}' +        docs_by_gen = [ +            (self.make_document(doc.doc_id, 'test:1|z:2', new_doc), 10, +             'T-sid')] +        new_gen, _ = self.st.sync_exchange( +            docs_by_gen, 'other-replica', last_known_generation=0, +            last_known_trans_id=None, return_doc_cb=self.receive_doc) +        self.assertEqual(expected, [c[:-1] for c in self.other_changes]) +        self.assertEqual(3, new_gen) + +    def test_sync_exchange_with_concurrent_updates(self): + +        def after_whatschanged_cb(state): +            if state != 'after whats_changed': +                return +            self.db.create_doc_from_json('{"new": "doc"}') + +        self.set_trace_hook(after_whatschanged_cb) +        doc = self.db.create_doc_from_json(simple_doc) +        self.assertTransactionLog([doc.doc_id], self.db) +        new_doc = '{"key": "altval"}' +        docs_by_gen = [ +            (self.make_document(doc.doc_id, 'test:1|z:2', new_doc), 10, +             'T-sid')] +        new_gen, _ = self.st.sync_exchange( +            docs_by_gen, 'other-replica', last_known_generation=0, +            last_known_trans_id=None, return_doc_cb=self.receive_doc) +        self.assertEqual(([], 2), (self.other_changes, new_gen)) + +    def test_sync_exchange_converged_handling(self): +        doc = self.db.create_doc_from_json(simple_doc) +        docs_by_gen = [ +            (self.make_document('new', 'other:1', '{}'), 4, 'T-foo'), +            (self.make_document(doc.doc_id, doc.rev, doc.get_json()), 5, +             'T-bar')] +        new_gen, _ = self.st.sync_exchange( +            docs_by_gen, 'other-replica', last_known_generation=0, +            last_known_trans_id=None, return_doc_cb=self.receive_doc) +        self.assertEqual(([], 2), (self.other_changes, new_gen)) + +    def test_sync_exchange_detect_incomplete_exchange(self): +        def before_get_docs_explode(state): +            if state != 'before get_docs': +                return +            raise errors.U1DBError("fail") +        self.set_trace_hook(before_get_docs_explode) +        # suppress traceback printing in the wsgiref server +        self.patch(simple_server.ServerHandler, +                   'log_exception', lambda h, exc_info: None) +        doc = self.db.create_doc_from_json(simple_doc) +        self.assertTransactionLog([doc.doc_id], self.db) +        self.assertRaises( +            (errors.U1DBError, errors.BrokenSyncStream), +            self.st.sync_exchange, [], 'other-replica', +            last_known_generation=0, last_known_trans_id=None, +            return_doc_cb=self.receive_doc) + +    def test_sync_exchange_doc_ids(self): +        sync_exchange_doc_ids = getattr(self.st, 'sync_exchange_doc_ids', None) +        if sync_exchange_doc_ids is None: +            self.skipTest("sync_exchange_doc_ids not implemented") +        db2 = self.create_database('test2') +        doc = db2.create_doc_from_json(simple_doc) +        new_gen, trans_id = sync_exchange_doc_ids( +            db2, [(doc.doc_id, 10, 'T-sid')], 0, None, +            return_doc_cb=self.receive_doc) +        self.assertGetDoc(self.db, doc.doc_id, doc.rev, simple_doc, False) +        self.assertTransactionLog([doc.doc_id], self.db) +        last_trans_id = self.getLastTransId(self.db) +        self.assertEqual(([], 1, last_trans_id), +                         (self.other_changes, new_gen, trans_id)) +        self.assertEqual(10, self.st.get_sync_info(db2._replica_uid)[3]) + +    def test__set_trace_hook(self): +        called = [] + +        def cb(state): +            called.append(state) + +        self.set_trace_hook(cb) +        self.st.sync_exchange([], 'replica', 0, None, self.receive_doc) +        self.st.record_sync_info('replica', 0, 'T-sid') +        self.assertEqual(['before whats_changed', +                          'after whats_changed', +                          'before get_docs', +                          'record_sync_info', +                          ], +                         called) + +    def test__set_trace_hook_shallow(self): +        if (self.st._set_trace_hook_shallow == self.st._set_trace_hook +            or +            self.st._set_trace_hook_shallow.im_func == +                SyncTarget._set_trace_hook_shallow.im_func): +            # shallow same as full +            expected = ['before whats_changed', +                        'after whats_changed', +                        'before get_docs', +                        'record_sync_info', +                        ] +        else: +            expected = ['sync_exchange', 'record_sync_info'] + +        called = [] + +        def cb(state): +            called.append(state) + +        self.set_trace_hook(cb, shallow=True) +        self.st.sync_exchange([], 'replica', 0, None, self.receive_doc) +        self.st.record_sync_info('replica', 0, 'T-sid') +        self.assertEqual(expected, called) + + +def sync_via_synchronizer(test, db_source, db_target, trace_hook=None, +                          trace_hook_shallow=None): +    target = db_target.get_sync_target() +    trace_hook = trace_hook or trace_hook_shallow +    if trace_hook: +        target._set_trace_hook(trace_hook) +    return sync.Synchronizer(db_source, target).sync() + + +sync_scenarios = [] +for name, scenario in tests.LOCAL_DATABASES_SCENARIOS: +    scenario = dict(scenario) +    scenario['do_sync'] = sync_via_synchronizer +    sync_scenarios.append((name, scenario)) +    scenario = dict(scenario) + + +def make_database_for_http_test(test, replica_uid): +    if test.server is None: +        test.startServer() +    db = test.request_state._create_database(replica_uid) +    try: +        http_at = test._http_at +    except AttributeError: +        http_at = test._http_at = {} +    http_at[db] = replica_uid +    return db + + +def copy_database_for_http_test(test, db): +    # DO NOT COPY OR REUSE THIS CODE OUTSIDE TESTS: COPYING U1DB DATABASES IS +    # THE WRONG THING TO DO, THE ONLY REASON WE DO SO HERE IS TO TEST THAT WE +    # CORRECTLY DETECT IT HAPPENING SO THAT WE CAN RAISE ERRORS RATHER THAN +    # CORRUPT USER DATA. USE SYNC INSTEAD, OR WE WILL SEND NINJA TO YOUR HOUSE. +    if test.server is None: +        test.startServer() +    new_db = test.request_state._copy_database(db) +    try: +        http_at = test._http_at +    except AttributeError: +        http_at = test._http_at = {} +    path = db._replica_uid +    while path in http_at.values(): +        path += 'copy' +    http_at[new_db] = path +    return new_db + + +def sync_via_synchronizer_and_http(test, db_source, db_target, +                                   trace_hook=None, trace_hook_shallow=None): +    if trace_hook: +        test.skipTest("full trace hook unsupported over http") +    path = test._http_at[db_target] +    target = http_target.HTTPSyncTarget.connect(test.getURL(path)) +    if trace_hook_shallow: +        target._set_trace_hook_shallow(trace_hook_shallow) +    return sync.Synchronizer(db_source, target).sync() + + +sync_scenarios.append(('pyhttp', { +    'make_database_for_test': make_database_for_http_test, +    'copy_database_for_test': copy_database_for_http_test, +    'make_document_for_test': tests.make_document_for_test, +    'make_app_with_state': make_http_app, +    'do_sync': sync_via_synchronizer_and_http +})) + + +class DatabaseSyncTests(tests.DatabaseBaseTests, +                        tests.TestCaseWithServer): + +    scenarios = sync_scenarios +    do_sync = None                 # set by scenarios + +    def create_database(self, replica_uid, sync_role=None): +        if replica_uid == 'test' and sync_role is None: +            # created up the chain by base class but unused +            return None +        db = self.create_database_for_role(replica_uid, sync_role) +        if sync_role: +            self._use_tracking[db] = (replica_uid, sync_role) +        return db + +    def create_database_for_role(self, replica_uid, sync_role): +        # hook point for reuse +        return super(DatabaseSyncTests, self).create_database(replica_uid) + +    def copy_database(self, db, sync_role=None): +        # DO NOT COPY OR REUSE THIS CODE OUTSIDE TESTS: COPYING U1DB DATABASES +        # IS THE WRONG THING TO DO, THE ONLY REASON WE DO SO HERE IS TO TEST +        # THAT WE CORRECTLY DETECT IT HAPPENING SO THAT WE CAN RAISE ERRORS +        # RATHER THAN CORRUPT USER DATA. USE SYNC INSTEAD, OR WE WILL SEND +        # NINJA TO YOUR HOUSE. +        db_copy = super(DatabaseSyncTests, self).copy_database(db) +        name, orig_sync_role = self._use_tracking[db] +        self._use_tracking[db_copy] = (name + '(copy)', sync_role +                                       or orig_sync_role) +        return db_copy + +    def sync(self, db_from, db_to, trace_hook=None, +             trace_hook_shallow=None): +        from_name, from_sync_role = self._use_tracking[db_from] +        to_name, to_sync_role = self._use_tracking[db_to] +        if from_sync_role not in ('source', 'both'): +            raise Exception("%s marked for %s use but used as source" % +                            (from_name, from_sync_role)) +        if to_sync_role not in ('target', 'both'): +            raise Exception("%s marked for %s use but used as target" % +                            (to_name, to_sync_role)) +        return self.do_sync(self, db_from, db_to, trace_hook, +                            trace_hook_shallow) + +    def setUp(self): +        self._use_tracking = {} +        super(DatabaseSyncTests, self).setUp() + +    def assertLastExchangeLog(self, db, expected): +        log = getattr(db, '_last_exchange_log', None) +        if log is None: +            return +        self.assertEqual(expected, log) + +    def test_sync_tracks_db_generation_of_other(self): +        self.db1 = self.create_database('test1', 'source') +        self.db2 = self.create_database('test2', 'target') +        self.assertEqual(0, self.sync(self.db1, self.db2)) +        self.assertEqual( +            (0, ''), self.db1._get_replica_gen_and_trans_id('test2')) +        self.assertEqual( +            (0, ''), self.db2._get_replica_gen_and_trans_id('test1')) +        self.assertLastExchangeLog(self.db2, +                                   {'receive': +                                       {'docs': [], 'last_known_gen': 0}, +                                    'return': +                                       {'docs': [], 'last_gen': 0}}) + +    def test_sync_autoresolves(self): +        self.db1 = self.create_database('test1', 'source') +        self.db2 = self.create_database('test2', 'target') +        doc1 = self.db1.create_doc_from_json(simple_doc, doc_id='doc') +        rev1 = doc1.rev +        doc2 = self.db2.create_doc_from_json(simple_doc, doc_id='doc') +        rev2 = doc2.rev +        self.sync(self.db1, self.db2) +        doc = self.db1.get_doc('doc') +        self.assertFalse(doc.has_conflicts) +        self.assertEqual(doc.rev, self.db2.get_doc('doc').rev) +        v = vectorclock.VectorClockRev(doc.rev) +        self.assertTrue(v.is_newer(vectorclock.VectorClockRev(rev1))) +        self.assertTrue(v.is_newer(vectorclock.VectorClockRev(rev2))) + +    def test_sync_autoresolves_moar(self): +        # here we test that when a database that has a conflicted document is +        # the source of a sync, and the target database has a revision of the +        # conflicted document that is newer than the source database's, and +        # that target's database's document's content is the same as the +        # source's document's conflict's, the source's document's conflict gets +        # autoresolved, and the source's document's revision bumped. +        # +        # idea is as follows: +        # A          B +        # a1         - +        #   `-------> +        # a1         a1 +        # v          v +        # a2         a1b1 +        #   `-------> +        # a1b1+a2    a1b1 +        #            v +        # a1b1+a2    a1b2 (a1b2 has same content as a2) +        #   `-------> +        # a3b2       a1b2 (autoresolved) +        #   `-------> +        # a3b2       a3b2 +        self.db1 = self.create_database('test1', 'source') +        self.db2 = self.create_database('test2', 'target') +        self.db1.create_doc_from_json(simple_doc, doc_id='doc') +        self.sync(self.db1, self.db2) +        for db, content in [(self.db1, '{}'), (self.db2, '{"hi": 42}')]: +            doc = db.get_doc('doc') +            doc.set_json(content) +            db.put_doc(doc) +        self.sync(self.db1, self.db2) +        # db1 and db2 now both have a doc of {hi:42}, but db1 has a conflict +        doc = self.db1.get_doc('doc') +        rev1 = doc.rev +        self.assertTrue(doc.has_conflicts) +        # set db2 to have a doc of {} (same as db1 before the conflict) +        doc = self.db2.get_doc('doc') +        doc.set_json('{}') +        self.db2.put_doc(doc) +        rev2 = doc.rev +        # sync it across +        self.sync(self.db1, self.db2) +        # tadaa! +        doc = self.db1.get_doc('doc') +        self.assertFalse(doc.has_conflicts) +        vec1 = vectorclock.VectorClockRev(rev1) +        vec2 = vectorclock.VectorClockRev(rev2) +        vec3 = vectorclock.VectorClockRev(doc.rev) +        self.assertTrue(vec3.is_newer(vec1)) +        self.assertTrue(vec3.is_newer(vec2)) +        # because the conflict is on the source, sync it another time +        self.sync(self.db1, self.db2) +        # make sure db2 now has the exact same thing +        self.assertEqual(self.db1.get_doc('doc'), self.db2.get_doc('doc')) + +    def test_sync_autoresolves_moar_backwards(self): +        # here we test that when a database that has a conflicted document is +        # the target of a sync, and the source database has a revision of the +        # conflicted document that is newer than the target database's, and +        # that source's database's document's content is the same as the +        # target's document's conflict's, the target's document's conflict gets +        # autoresolved, and the document's revision bumped. +        # +        # idea is as follows: +        # A          B +        # a1         - +        #   `-------> +        # a1         a1 +        # v          v +        # a2         a1b1 +        #   `-------> +        # a1b1+a2    a1b1 +        #            v +        # a1b1+a2    a1b2 (a1b2 has same content as a2) +        #   <-------' +        # a3b2       a3b2 (autoresolved and propagated) +        self.db1 = self.create_database('test1', 'both') +        self.db2 = self.create_database('test2', 'both') +        self.db1.create_doc_from_json(simple_doc, doc_id='doc') +        self.sync(self.db1, self.db2) +        for db, content in [(self.db1, '{}'), (self.db2, '{"hi": 42}')]: +            doc = db.get_doc('doc') +            doc.set_json(content) +            db.put_doc(doc) +        self.sync(self.db1, self.db2) +        # db1 and db2 now both have a doc of {hi:42}, but db1 has a conflict +        doc = self.db1.get_doc('doc') +        rev1 = doc.rev +        self.assertTrue(doc.has_conflicts) +        revc = self.db1.get_doc_conflicts('doc')[-1].rev +        # set db2 to have a doc of {} (same as db1 before the conflict) +        doc = self.db2.get_doc('doc') +        doc.set_json('{}') +        self.db2.put_doc(doc) +        rev2 = doc.rev +        # sync it across +        self.sync(self.db2, self.db1) +        # tadaa! +        doc = self.db1.get_doc('doc') +        self.assertFalse(doc.has_conflicts) +        vec1 = vectorclock.VectorClockRev(rev1) +        vec2 = vectorclock.VectorClockRev(rev2) +        vec3 = vectorclock.VectorClockRev(doc.rev) +        vecc = vectorclock.VectorClockRev(revc) +        self.assertTrue(vec3.is_newer(vec1)) +        self.assertTrue(vec3.is_newer(vec2)) +        self.assertTrue(vec3.is_newer(vecc)) +        # make sure db2 now has the exact same thing +        self.assertEqual(self.db1.get_doc('doc'), self.db2.get_doc('doc')) + +    def test_sync_autoresolves_moar_backwards_three(self): +        # same as autoresolves_moar_backwards, but with three databases (note +        # all the syncs go in the same direction -- this is a more natural +        # scenario): +        # +        # A          B          C +        # a1         -          - +        #   `-------> +        # a1         a1         - +        #              `-------> +        # a1         a1         a1 +        # v          v +        # a2         a1b1       a1 +        #  `-------------------> +        # a2         a1b1       a2 +        #              `-------> +        #            a2+a1b1    a2 +        #                       v +        # a2         a2+a1b1    a2c1 (same as a1b1) +        #  `-------------------> +        # a2c1       a2+a1b1    a2c1 +        #   `-------> +        # a2b2c1     a2b2c1     a2c1 +        self.db1 = self.create_database('test1', 'source') +        self.db2 = self.create_database('test2', 'both') +        self.db3 = self.create_database('test3', 'target') +        self.db1.create_doc_from_json(simple_doc, doc_id='doc') +        self.sync(self.db1, self.db2) +        self.sync(self.db2, self.db3) +        for db, content in [(self.db2, '{"hi": 42}'), +                            (self.db1, '{}'), +                            ]: +            doc = db.get_doc('doc') +            doc.set_json(content) +            db.put_doc(doc) +        self.sync(self.db1, self.db3) +        self.sync(self.db2, self.db3) +        # db2 and db3 now both have a doc of {}, but db2 has a +        # conflict +        doc = self.db2.get_doc('doc') +        self.assertTrue(doc.has_conflicts) +        revc = self.db2.get_doc_conflicts('doc')[-1].rev +        self.assertEqual('{}', doc.get_json()) +        self.assertEqual(self.db3.get_doc('doc').get_json(), doc.get_json()) +        self.assertEqual(self.db3.get_doc('doc').rev, doc.rev) +        # set db3 to have a doc of {hi:42} (same as db2 before the conflict) +        doc = self.db3.get_doc('doc') +        doc.set_json('{"hi": 42}') +        self.db3.put_doc(doc) +        rev3 = doc.rev +        # sync it across to db1 +        self.sync(self.db1, self.db3) +        # db1 now has hi:42, with a rev that is newer than db2's doc +        doc = self.db1.get_doc('doc') +        rev1 = doc.rev +        self.assertFalse(doc.has_conflicts) +        self.assertEqual('{"hi": 42}', doc.get_json()) +        VCR = vectorclock.VectorClockRev +        self.assertTrue(VCR(rev1).is_newer(VCR(self.db2.get_doc('doc').rev))) +        # so sync it to db2 +        self.sync(self.db1, self.db2) +        # tadaa! +        doc = self.db2.get_doc('doc') +        self.assertFalse(doc.has_conflicts) +        # db2's revision of the document is strictly newer than db1's before +        # the sync, and db3's before that sync way back when +        self.assertTrue(VCR(doc.rev).is_newer(VCR(rev1))) +        self.assertTrue(VCR(doc.rev).is_newer(VCR(rev3))) +        self.assertTrue(VCR(doc.rev).is_newer(VCR(revc))) +        # make sure both dbs now have the exact same thing +        self.assertEqual(self.db1.get_doc('doc'), self.db2.get_doc('doc')) + +    def test_sync_puts_changes(self): +        self.db1 = self.create_database('test1', 'source') +        self.db2 = self.create_database('test2', 'target') +        doc = self.db1.create_doc_from_json(simple_doc) +        self.assertEqual(1, self.sync(self.db1, self.db2)) +        self.assertGetDoc(self.db2, doc.doc_id, doc.rev, simple_doc, False) +        self.assertEqual(1, self.db1._get_replica_gen_and_trans_id('test2')[0]) +        self.assertEqual(1, self.db2._get_replica_gen_and_trans_id('test1')[0]) +        self.assertLastExchangeLog(self.db2, +                                   {'receive': +                                       {'docs': [(doc.doc_id, doc.rev)], +                                        'source_uid': 'test1', +                                        'source_gen': 1, +                                        'last_known_gen': 0}, +                                    'return': {'docs': [], 'last_gen': 1}}) + +    def test_sync_pulls_changes(self): +        self.db1 = self.create_database('test1', 'source') +        self.db2 = self.create_database('test2', 'target') +        doc = self.db2.create_doc_from_json(simple_doc) +        self.db1.create_index('test-idx', 'key') +        self.assertEqual(0, self.sync(self.db1, self.db2)) +        self.assertGetDoc(self.db1, doc.doc_id, doc.rev, simple_doc, False) +        self.assertEqual(1, self.db1._get_replica_gen_and_trans_id('test2')[0]) +        self.assertEqual(1, self.db2._get_replica_gen_and_trans_id('test1')[0]) +        self.assertLastExchangeLog(self.db2, +                                   {'receive': +                                       {'docs': [], 'last_known_gen': 0}, +                                    'return': +                                       {'docs': [(doc.doc_id, doc.rev)], +                                    'last_gen': 1}}) +        self.assertEqual([doc], self.db1.get_from_index('test-idx', 'value')) + +    def test_sync_pulling_doesnt_update_other_if_changed(self): +        self.db1 = self.create_database('test1', 'source') +        self.db2 = self.create_database('test2', 'target') +        doc = self.db2.create_doc_from_json(simple_doc) +        # After the local side has sent its list of docs, before we start +        # receiving the "targets" response, we update the local database with a +        # new record. +        # When we finish synchronizing, we can notice that something locally +        # was updated, and we cannot tell c2 our new updated generation + +        def before_get_docs(state): +            if state != 'before get_docs': +                return +            self.db1.create_doc_from_json(simple_doc) + +        self.assertEqual(0, self.sync(self.db1, self.db2, +                                      trace_hook=before_get_docs)) +        self.assertLastExchangeLog(self.db2, +                                   {'receive': +                                       {'docs': [], 'last_known_gen': 0}, +                                    'return': +                                       {'docs': [(doc.doc_id, doc.rev)], +                                    'last_gen': 1}}) +        self.assertEqual(1, self.db1._get_replica_gen_and_trans_id('test2')[0]) +        # c2 should not have gotten a '_record_sync_info' call, because the +        # local database had been updated more than just by the messages +        # returned from c2. +        self.assertEqual( +            (0, ''), self.db2._get_replica_gen_and_trans_id('test1')) + +    def test_sync_doesnt_update_other_if_nothing_pulled(self): +        self.db1 = self.create_database('test1', 'source') +        self.db2 = self.create_database('test2', 'target') +        self.db1.create_doc_from_json(simple_doc) + +        def no_record_sync_info(state): +            if state != 'record_sync_info': +                return +            self.fail('SyncTarget.record_sync_info was called') +        self.assertEqual(1, self.sync(self.db1, self.db2, +                                      trace_hook_shallow=no_record_sync_info)) +        self.assertEqual( +            1, +            self.db2._get_replica_gen_and_trans_id(self.db1._replica_uid)[0]) + +    def test_sync_ignores_convergence(self): +        self.db1 = self.create_database('test1', 'source') +        self.db2 = self.create_database('test2', 'both') +        doc = self.db1.create_doc_from_json(simple_doc) +        self.db3 = self.create_database('test3', 'target') +        self.assertEqual(1, self.sync(self.db1, self.db3)) +        self.assertEqual(0, self.sync(self.db2, self.db3)) +        self.assertEqual(1, self.sync(self.db1, self.db2)) +        self.assertLastExchangeLog(self.db2, +                                   {'receive': +                                       {'docs': [(doc.doc_id, doc.rev)], +                                    'source_uid': 'test1', +                                    'source_gen': 1, 'last_known_gen': 0}, +                                    'return': {'docs': [], 'last_gen': 1}}) + +    def test_sync_ignores_superseded(self): +        self.db1 = self.create_database('test1', 'both') +        self.db2 = self.create_database('test2', 'both') +        doc = self.db1.create_doc_from_json(simple_doc) +        doc_rev1 = doc.rev +        self.db3 = self.create_database('test3', 'target') +        self.sync(self.db1, self.db3) +        self.sync(self.db2, self.db3) +        new_content = '{"key": "altval"}' +        doc.set_json(new_content) +        self.db1.put_doc(doc) +        doc_rev2 = doc.rev +        self.sync(self.db2, self.db1) +        self.assertLastExchangeLog(self.db1, +                                   {'receive': +                                       {'docs': [(doc.doc_id, doc_rev1)], +                                    'source_uid': 'test2', +                                    'source_gen': 1, 'last_known_gen': 0}, +                                    'return': +                                       {'docs': [(doc.doc_id, doc_rev2)], +                                    'last_gen': 2}}) +        self.assertGetDoc(self.db1, doc.doc_id, doc_rev2, new_content, False) + +    def test_sync_sees_remote_conflicted(self): +        self.db1 = self.create_database('test1', 'source') +        self.db2 = self.create_database('test2', 'target') +        doc1 = self.db1.create_doc_from_json(simple_doc) +        doc_id = doc1.doc_id +        doc1_rev = doc1.rev +        self.db1.create_index('test-idx', 'key') +        new_doc = '{"key": "altval"}' +        doc2 = self.db2.create_doc_from_json(new_doc, doc_id=doc_id) +        doc2_rev = doc2.rev +        self.assertTransactionLog([doc1.doc_id], self.db1) +        self.sync(self.db1, self.db2) +        self.assertLastExchangeLog(self.db2, +                                   {'receive': +                                       {'docs': [(doc_id, doc1_rev)], +                                    'source_uid': 'test1', +                                    'source_gen': 1, 'last_known_gen': 0}, +                                    'return': +                                       {'docs': [(doc_id, doc2_rev)], +                                    'last_gen': 1}}) +        self.assertTransactionLog([doc_id, doc_id], self.db1) +        self.assertGetDoc(self.db1, doc_id, doc2_rev, new_doc, True) +        self.assertGetDoc(self.db2, doc_id, doc2_rev, new_doc, False) +        from_idx = self.db1.get_from_index('test-idx', 'altval')[0] +        self.assertEqual(doc2.doc_id, from_idx.doc_id) +        self.assertEqual(doc2.rev, from_idx.rev) +        self.assertTrue(from_idx.has_conflicts) +        self.assertEqual([], self.db1.get_from_index('test-idx', 'value')) + +    def test_sync_sees_remote_delete_conflicted(self): +        self.db1 = self.create_database('test1', 'source') +        self.db2 = self.create_database('test2', 'target') +        doc1 = self.db1.create_doc_from_json(simple_doc) +        doc_id = doc1.doc_id +        self.db1.create_index('test-idx', 'key') +        self.sync(self.db1, self.db2) +        doc2 = self.make_document(doc1.doc_id, doc1.rev, doc1.get_json()) +        new_doc = '{"key": "altval"}' +        doc1.set_json(new_doc) +        self.db1.put_doc(doc1) +        self.db2.delete_doc(doc2) +        self.assertTransactionLog([doc_id, doc_id], self.db1) +        self.sync(self.db1, self.db2) +        self.assertLastExchangeLog(self.db2, +                                   {'receive': +                                       {'docs': [(doc_id, doc1.rev)], +                                    'source_uid': 'test1', +                                    'source_gen': 2, 'last_known_gen': 1}, +                                    'return': {'docs': [(doc_id, doc2.rev)], +                                    'last_gen': 2}}) +        self.assertTransactionLog([doc_id, doc_id, doc_id], self.db1) +        self.assertGetDocIncludeDeleted(self.db1, doc_id, doc2.rev, None, True) +        self.assertGetDocIncludeDeleted( +            self.db2, doc_id, doc2.rev, None, False) +        self.assertEqual([], self.db1.get_from_index('test-idx', 'value')) + +    def test_sync_local_race_conflicted(self): +        self.db1 = self.create_database('test1', 'source') +        self.db2 = self.create_database('test2', 'target') +        doc = self.db1.create_doc_from_json(simple_doc) +        doc_id = doc.doc_id +        doc1_rev = doc.rev +        self.db1.create_index('test-idx', 'key') +        self.sync(self.db1, self.db2) +        content1 = '{"key": "localval"}' +        content2 = '{"key": "altval"}' +        doc.set_json(content2) +        self.db2.put_doc(doc) +        doc2_rev2 = doc.rev +        triggered = [] + +        def after_whatschanged(state): +            if state != 'after whats_changed': +                return +            triggered.append(True) +            doc = self.make_document(doc_id, doc1_rev, content1) +            self.db1.put_doc(doc) + +        self.sync(self.db1, self.db2, trace_hook=after_whatschanged) +        self.assertEqual([True], triggered) +        self.assertGetDoc(self.db1, doc_id, doc2_rev2, content2, True) +        from_idx = self.db1.get_from_index('test-idx', 'altval')[0] +        self.assertEqual(doc.doc_id, from_idx.doc_id) +        self.assertEqual(doc.rev, from_idx.rev) +        self.assertTrue(from_idx.has_conflicts) +        self.assertEqual([], self.db1.get_from_index('test-idx', 'value')) +        self.assertEqual([], self.db1.get_from_index('test-idx', 'localval')) + +    def test_sync_propagates_deletes(self): +        self.db1 = self.create_database('test1', 'source') +        self.db2 = self.create_database('test2', 'both') +        doc1 = self.db1.create_doc_from_json(simple_doc) +        doc_id = doc1.doc_id +        self.db1.create_index('test-idx', 'key') +        self.sync(self.db1, self.db2) +        self.db2.create_index('test-idx', 'key') +        self.db3 = self.create_database('test3', 'target') +        self.sync(self.db1, self.db3) +        self.db1.delete_doc(doc1) +        deleted_rev = doc1.rev +        self.sync(self.db1, self.db2) +        self.assertLastExchangeLog(self.db2, +                                   {'receive': +                                       {'docs': [(doc_id, deleted_rev)], +                                    'source_uid': 'test1', +                                    'source_gen': 2, 'last_known_gen': 1}, +                                    'return': {'docs': [], 'last_gen': 2}}) +        self.assertGetDocIncludeDeleted( +            self.db1, doc_id, deleted_rev, None, False) +        self.assertGetDocIncludeDeleted( +            self.db2, doc_id, deleted_rev, None, False) +        self.assertEqual([], self.db1.get_from_index('test-idx', 'value')) +        self.assertEqual([], self.db2.get_from_index('test-idx', 'value')) +        self.sync(self.db2, self.db3) +        self.assertLastExchangeLog(self.db3, +                                   {'receive': +                                       {'docs': [(doc_id, deleted_rev)], +                                        'source_uid': 'test2', +                                        'source_gen': 2, +                                        'last_known_gen': 0}, +                                    'return': +                                       {'docs': [], 'last_gen': 2}}) +        self.assertGetDocIncludeDeleted( +            self.db3, doc_id, deleted_rev, None, False) + +    def test_sync_propagates_resolution(self): +        self.db1 = self.create_database('test1', 'both') +        self.db2 = self.create_database('test2', 'both') +        doc1 = self.db1.create_doc_from_json('{"a": 1}', doc_id='the-doc') +        db3 = self.create_database('test3', 'both') +        self.sync(self.db2, self.db1) +        self.assertEqual( +            self.db1._get_generation_info(), +            self.db2._get_replica_gen_and_trans_id(self.db1._replica_uid)) +        self.assertEqual( +            self.db2._get_generation_info(), +            self.db1._get_replica_gen_and_trans_id(self.db2._replica_uid)) +        self.sync(db3, self.db1) +        # update on 2 +        doc2 = self.make_document('the-doc', doc1.rev, '{"a": 2}') +        self.db2.put_doc(doc2) +        self.sync(self.db2, db3) +        self.assertEqual(db3.get_doc('the-doc').rev, doc2.rev) +        # update on 1 +        doc1.set_json('{"a": 3}') +        self.db1.put_doc(doc1) +        # conflicts +        self.sync(self.db2, self.db1) +        self.sync(db3, self.db1) +        self.assertTrue(self.db2.get_doc('the-doc').has_conflicts) +        self.assertTrue(db3.get_doc('the-doc').has_conflicts) +        # resolve +        conflicts = self.db2.get_doc_conflicts('the-doc') +        doc4 = self.make_document('the-doc', None, '{"a": 4}') +        revs = [doc.rev for doc in conflicts] +        self.db2.resolve_doc(doc4, revs) +        doc2 = self.db2.get_doc('the-doc') +        self.assertEqual(doc4.get_json(), doc2.get_json()) +        self.assertFalse(doc2.has_conflicts) +        self.sync(self.db2, db3) +        doc3 = db3.get_doc('the-doc') +        self.assertEqual(doc4.get_json(), doc3.get_json()) +        self.assertFalse(doc3.has_conflicts) + +    def test_sync_supersedes_conflicts(self): +        self.db1 = self.create_database('test1', 'both') +        self.db2 = self.create_database('test2', 'target') +        db3 = self.create_database('test3', 'both') +        doc1 = self.db1.create_doc_from_json('{"a": 1}', doc_id='the-doc') +        self.db2.create_doc_from_json('{"b": 1}', doc_id='the-doc') +        db3.create_doc_from_json('{"c": 1}', doc_id='the-doc') +        self.sync(db3, self.db1) +        self.assertEqual( +            self.db1._get_generation_info(), +            db3._get_replica_gen_and_trans_id(self.db1._replica_uid)) +        self.assertEqual( +            db3._get_generation_info(), +            self.db1._get_replica_gen_and_trans_id(db3._replica_uid)) +        self.sync(db3, self.db2) +        self.assertEqual( +            self.db2._get_generation_info(), +            db3._get_replica_gen_and_trans_id(self.db2._replica_uid)) +        self.assertEqual( +            db3._get_generation_info(), +            self.db2._get_replica_gen_and_trans_id(db3._replica_uid)) +        self.assertEqual(3, len(db3.get_doc_conflicts('the-doc'))) +        doc1.set_json('{"a": 2}') +        self.db1.put_doc(doc1) +        self.sync(db3, self.db1) +        # original doc1 should have been removed from conflicts +        self.assertEqual(3, len(db3.get_doc_conflicts('the-doc'))) + +    def test_sync_stops_after_get_sync_info(self): +        self.db1 = self.create_database('test1', 'source') +        self.db2 = self.create_database('test2', 'target') +        self.db1.create_doc_from_json(tests.simple_doc) +        self.sync(self.db1, self.db2) + +        def put_hook(state): +            self.fail("Tracehook triggered for %s" % (state,)) + +        self.sync(self.db1, self.db2, trace_hook_shallow=put_hook) + +    def test_sync_detects_rollback_in_source(self): +        self.db1 = self.create_database('test1', 'source') +        self.db2 = self.create_database('test2', 'target') +        self.db1.create_doc_from_json(tests.simple_doc, doc_id='doc1') +        self.sync(self.db1, self.db2) +        db1_copy = self.copy_database(self.db1) +        self.db1.create_doc_from_json(tests.simple_doc, doc_id='doc2') +        self.sync(self.db1, self.db2) +        self.assertRaises( +            errors.InvalidGeneration, self.sync, db1_copy, self.db2) + +    def test_sync_detects_rollback_in_target(self): +        self.db1 = self.create_database('test1', 'source') +        self.db2 = self.create_database('test2', 'target') +        self.db1.create_doc_from_json(tests.simple_doc, doc_id="divergent") +        self.sync(self.db1, self.db2) +        db2_copy = self.copy_database(self.db2) +        self.db2.create_doc_from_json(tests.simple_doc, doc_id='doc2') +        self.sync(self.db1, self.db2) +        self.assertRaises( +            errors.InvalidGeneration, self.sync, self.db1, db2_copy) + +    def test_sync_detects_diverged_source(self): +        self.db1 = self.create_database('test1', 'source') +        self.db2 = self.create_database('test2', 'target') +        db3 = self.copy_database(self.db1) +        self.db1.create_doc_from_json(tests.simple_doc, doc_id="divergent") +        db3.create_doc_from_json(tests.simple_doc, doc_id="divergent") +        self.sync(self.db1, self.db2) +        self.assertRaises( +            errors.InvalidTransactionId, self.sync, db3, self.db2) + +    def test_sync_detects_diverged_target(self): +        self.db1 = self.create_database('test1', 'source') +        self.db2 = self.create_database('test2', 'target') +        db3 = self.copy_database(self.db2) +        db3.create_doc_from_json(tests.nested_doc, doc_id="divergent") +        self.db1.create_doc_from_json(tests.simple_doc, doc_id="divergent") +        self.sync(self.db1, self.db2) +        self.assertRaises( +            errors.InvalidTransactionId, self.sync, self.db1, db3) + +    def test_sync_detects_rollback_and_divergence_in_source(self): +        self.db1 = self.create_database('test1', 'source') +        self.db2 = self.create_database('test2', 'target') +        self.db1.create_doc_from_json(tests.simple_doc, doc_id='doc1') +        self.sync(self.db1, self.db2) +        db1_copy = self.copy_database(self.db1) +        self.db1.create_doc_from_json(tests.simple_doc, doc_id='doc2') +        self.db1.create_doc_from_json(tests.simple_doc, doc_id='doc3') +        self.sync(self.db1, self.db2) +        db1_copy.create_doc_from_json(tests.simple_doc, doc_id='doc2') +        db1_copy.create_doc_from_json(tests.simple_doc, doc_id='doc3') +        self.assertRaises( +            errors.InvalidTransactionId, self.sync, db1_copy, self.db2) + +    def test_sync_detects_rollback_and_divergence_in_target(self): +        self.db1 = self.create_database('test1', 'source') +        self.db2 = self.create_database('test2', 'target') +        self.db1.create_doc_from_json(tests.simple_doc, doc_id="divergent") +        self.sync(self.db1, self.db2) +        db2_copy = self.copy_database(self.db2) +        self.db2.create_doc_from_json(tests.simple_doc, doc_id='doc2') +        self.db2.create_doc_from_json(tests.simple_doc, doc_id='doc3') +        self.sync(self.db1, self.db2) +        db2_copy.create_doc_from_json(tests.simple_doc, doc_id='doc2') +        db2_copy.create_doc_from_json(tests.simple_doc, doc_id='doc3') +        self.assertRaises( +            errors.InvalidTransactionId, self.sync, self.db1, db2_copy) + + +class TestDbSync(tests.TestCaseWithServer): +    """Test db.sync remote sync shortcut""" + +    scenarios = [ +        ('py-http', { +            'make_app_with_state': make_http_app, +            'make_database_for_test': tests.make_memory_database_for_test, +        }), +        ('py-oauth-http', { +            'make_app_with_state': make_oauth_http_app, +            'make_database_for_test': tests.make_memory_database_for_test, +            'oauth': True +        }), +    ] + +    oauth = False + +    def do_sync(self, target_name): +        if self.oauth: +            path = '~/' + target_name +            extra = dict(creds={'oauth': { +                'consumer_key': tests.consumer1.key, +                'consumer_secret': tests.consumer1.secret, +                'token_key': tests.token1.key, +                'token_secret': tests.token1.secret, +            }}) +        else: +            path = target_name +            extra = {} +        target_url = self.getURL(path) +        return self.db.sync(target_url, **extra) + +    def setUp(self): +        super(TestDbSync, self).setUp() +        self.startServer() +        self.db = self.make_database_for_test(self, 'test1') +        self.db2 = self.request_state._create_database('test2.db') + +    def test_db_sync(self): +        doc1 = self.db.create_doc_from_json(tests.simple_doc) +        doc2 = self.db2.create_doc_from_json(tests.nested_doc) +        local_gen_before_sync = self.do_sync('test2.db') +        gen, _, changes = self.db.whats_changed(local_gen_before_sync) +        self.assertEqual(1, len(changes)) +        self.assertEqual(doc2.doc_id, changes[0][0]) +        self.assertEqual(1, gen - local_gen_before_sync) +        self.assertGetDoc(self.db2, doc1.doc_id, doc1.rev, tests.simple_doc, +                          False) +        self.assertGetDoc(self.db, doc2.doc_id, doc2.rev, tests.nested_doc, +                          False) + +    def test_db_sync_autocreate(self): +        doc1 = self.db.create_doc_from_json(tests.simple_doc) +        local_gen_before_sync = self.do_sync('test3.db') +        gen, _, changes = self.db.whats_changed(local_gen_before_sync) +        self.assertEqual(0, gen - local_gen_before_sync) +        db3 = self.request_state.open_database('test3.db') +        gen, _, changes = db3.whats_changed() +        self.assertEqual(1, len(changes)) +        self.assertEqual(doc1.doc_id, changes[0][0]) +        self.assertGetDoc(db3, doc1.doc_id, doc1.rev, tests.simple_doc, +                          False) +        t_gen, _ = self.db._get_replica_gen_and_trans_id('test3.db') +        s_gen, _ = db3._get_replica_gen_and_trans_id('test1') +        self.assertEqual(1, t_gen) +        self.assertEqual(1, s_gen) + + +class TestRemoteSyncIntegration(tests.TestCaseWithServer): +    """Integration tests for the most common sync scenario local -> remote""" + +    make_app_with_state = staticmethod(make_http_app) + +    def setUp(self): +        super(TestRemoteSyncIntegration, self).setUp() +        self.startServer() +        self.db1 = inmemory.InMemoryDatabase('test1') +        self.db2 = self.request_state._create_database('test2') + +    def test_sync_tracks_generations_incrementally(self): +        doc11 = self.db1.create_doc_from_json('{"a": 1}') +        doc12 = self.db1.create_doc_from_json('{"a": 2}') +        doc21 = self.db2.create_doc_from_json('{"b": 1}') +        doc22 = self.db2.create_doc_from_json('{"b": 2}') +        #sanity +        self.assertEqual(2, len(self.db1._get_transaction_log())) +        self.assertEqual(2, len(self.db2._get_transaction_log())) +        progress1 = [] +        progress2 = [] +        _do_set_replica_gen_and_trans_id = \ +            self.db1._do_set_replica_gen_and_trans_id + +        def set_sync_generation_witness1(other_uid, other_gen, trans_id): +            progress1.append((other_uid, other_gen, +                              [d for d, t in +                                  self.db1._get_transaction_log()[2:]])) +            _do_set_replica_gen_and_trans_id(other_uid, other_gen, trans_id) +        self.patch(self.db1, '_do_set_replica_gen_and_trans_id', +                   set_sync_generation_witness1) +        _do_set_replica_gen_and_trans_id2 = \ +            self.db2._do_set_replica_gen_and_trans_id + +        def set_sync_generation_witness2(other_uid, other_gen, trans_id): +            progress2.append((other_uid, other_gen, +                              [d for d, t in +                                  self.db2._get_transaction_log()[2:]])) +            _do_set_replica_gen_and_trans_id2(other_uid, other_gen, trans_id) +        self.patch(self.db2, '_do_set_replica_gen_and_trans_id', +                   set_sync_generation_witness2) + +        db2_url = self.getURL('test2') +        self.db1.sync(db2_url) + +        self.assertEqual([('test2', 1, [doc21.doc_id]), +                          ('test2', 2, [doc21.doc_id, doc22.doc_id]), +                          ('test2', 4, [doc21.doc_id, doc22.doc_id])], +                         progress1) +        self.assertEqual([('test1', 1, [doc11.doc_id]), +                          ('test1', 2, [doc11.doc_id, doc12.doc_id]), +                          ('test1', 4, [doc11.doc_id, doc12.doc_id])], +                         progress2) + + +load_tests = tests.load_with_scenarios diff --git a/common/src/leap/soledad/common/tests/u1db_tests/testing-certs/Makefile b/common/src/leap/soledad/common/tests/u1db_tests/testing-certs/Makefile new file mode 100644 index 00000000..2385e75b --- /dev/null +++ b/common/src/leap/soledad/common/tests/u1db_tests/testing-certs/Makefile @@ -0,0 +1,35 @@ +CATOP=./demoCA +ORIG_CONF=/usr/lib/ssl/openssl.cnf +ELEVEN_YEARS=-days 4015 + +init: +	cp $(ORIG_CONF) ca.conf +	install -d $(CATOP) +	install -d $(CATOP)/certs +	install -d $(CATOP)/crl +	install -d $(CATOP)/newcerts +	install -d $(CATOP)/private +	touch $(CATOP)/index.txt +	echo 01>$(CATOP)/crlnumber +	@echo '**** Making CA certificate ...' +	openssl req -nodes -new \ +	 	-newkey rsa -keyout $(CATOP)/private/cakey.pem \ +		-out $(CATOP)/careq.pem \ +		-multivalue-rdn \ +        -subj "/C=UK/ST=-/O=u1db LOCAL TESTING ONLY, DO NO TRUST/CN=u1db testing CA" +	openssl ca -config ./ca.conf -create_serial \ +		-out $(CATOP)/cacert.pem $(ELEVEN_YEARS) -batch \ +		-keyfile $(CATOP)/private/cakey.pem -selfsign \ +		-extensions v3_ca -infiles $(CATOP)/careq.pem + +pems: +	cp ./demoCA/cacert.pem . +	openssl req -new -config ca.conf \ +		-multivalue-rdn \ +	-subj "/O=u1db LOCAL TESTING ONLY, DO NOT TRUST/CN=localhost" \ +		-nodes -keyout testing.key -out newreq.pem $(ELEVEN_YEARS) +	openssl ca -batch -config ./ca.conf $(ELEVEN_YEARS) \ +		-policy policy_anything \ +		-out testing.cert -infiles newreq.pem + +.PHONY: init pems diff --git a/common/src/leap/soledad/common/tests/u1db_tests/testing-certs/cacert.pem b/common/src/leap/soledad/common/tests/u1db_tests/testing-certs/cacert.pem new file mode 100644 index 00000000..c019a730 --- /dev/null +++ b/common/src/leap/soledad/common/tests/u1db_tests/testing-certs/cacert.pem @@ -0,0 +1,58 @@ +Certificate: +    Data: +        Version: 3 (0x2) +        Serial Number: +            e4:de:01:76:c4:78:78:7e +    Signature Algorithm: sha1WithRSAEncryption +        Issuer: C=UK, ST=-, O=u1db LOCAL TESTING ONLY, DO NO TRUST, CN=u1db testing CA +        Validity +            Not Before: May  3 11:11:11 2012 GMT +            Not After : May  1 11:11:11 2023 GMT +        Subject: C=UK, ST=-, O=u1db LOCAL TESTING ONLY, DO NO TRUST, CN=u1db testing CA +        Subject Public Key Info: +            Public Key Algorithm: rsaEncryption +                Public-Key: (1024 bit) +                Modulus: +                    00:bc:91:a5:7f:7d:37:f7:06:c7:db:5b:83:6a:6b: +                    63:c3:8b:5c:f7:84:4d:97:6d:d4:be:bf:e7:79:a8: +                    c1:03:57:ec:90:d4:20:e7:02:95:d9:a6:49:e3:f9: +                    9a:ea:37:b9:b2:02:62:ab:40:d3:42:bb:4a:4e:a2: +                    47:71:0f:1d:a2:c5:94:a1:cf:35:d3:23:32:42:c0: +                    1e:8d:cb:08:58:fb:8a:5c:3e:ea:eb:d5:2c:ed:d6: +                    aa:09:b4:b5:7d:e3:45:c9:ae:c2:82:b2:ae:c0:81: +                    bc:24:06:65:a9:e7:e0:61:ac:25:ee:53:d3:d7:be: +                    22:f7:00:a2:ad:c6:0e:3a:39 +                Exponent: 65537 (0x10001) +        X509v3 extensions: +            X509v3 Subject Key Identifier:  +                DB:3D:93:51:6C:32:15:54:8F:10:50:FC:49:4F:36:15:28:BB:95:6D +            X509v3 Authority Key Identifier:  +                keyid:DB:3D:93:51:6C:32:15:54:8F:10:50:FC:49:4F:36:15:28:BB:95:6D + +            X509v3 Basic Constraints:  +                CA:TRUE +    Signature Algorithm: sha1WithRSAEncryption +         72:9b:c1:f7:07:65:83:36:25:4e:01:2f:b7:4a:f2:a4:00:28: +         80:c7:56:2c:32:39:90:13:61:4b:bb:12:c5:44:9d:42:57:85: +         28:19:70:69:e1:43:c8:bd:11:f6:94:df:91:2d:c3:ea:82:8d: +         b4:8f:5d:47:a3:00:99:53:29:93:27:6c:c5:da:c1:20:6f:ab: +         ec:4a:be:34:f3:8f:02:e5:0c:c0:03:ac:2b:33:41:71:4f:0a: +         72:5a:b4:26:1a:7f:81:bc:c0:95:8a:06:87:a8:11:9f:5c:73: +         38:df:5a:69:40:21:29:ad:46:23:56:75:e1:e9:8b:10:18:4c: +         7b:54 +-----BEGIN CERTIFICATE----- +MIICkjCCAfugAwIBAgIJAOTeAXbEeHh+MA0GCSqGSIb3DQEBBQUAMGIxCzAJBgNV +BAYTAlVLMQowCAYDVQQIDAEtMS0wKwYDVQQKDCR1MWRiIExPQ0FMIFRFU1RJTkcg +T05MWSwgRE8gTk8gVFJVU1QxGDAWBgNVBAMMD3UxZGIgdGVzdGluZyBDQTAeFw0x +MjA1MDMxMTExMTFaFw0yMzA1MDExMTExMTFaMGIxCzAJBgNVBAYTAlVLMQowCAYD +VQQIDAEtMS0wKwYDVQQKDCR1MWRiIExPQ0FMIFRFU1RJTkcgT05MWSwgRE8gTk8g +VFJVU1QxGDAWBgNVBAMMD3UxZGIgdGVzdGluZyBDQTCBnzANBgkqhkiG9w0BAQEF +AAOBjQAwgYkCgYEAvJGlf3039wbH21uDamtjw4tc94RNl23Uvr/neajBA1fskNQg +5wKV2aZJ4/ma6je5sgJiq0DTQrtKTqJHcQ8dosWUoc810yMyQsAejcsIWPuKXD7q +69Us7daqCbS1feNFya7CgrKuwIG8JAZlqefgYawl7lPT174i9wCircYOOjkCAwEA +AaNQME4wHQYDVR0OBBYEFNs9k1FsMhVUjxBQ/ElPNhUou5VtMB8GA1UdIwQYMBaA +FNs9k1FsMhVUjxBQ/ElPNhUou5VtMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEF +BQADgYEAcpvB9wdlgzYlTgEvt0rypAAogMdWLDI5kBNhS7sSxUSdQleFKBlwaeFD +yL0R9pTfkS3D6oKNtI9dR6MAmVMpkydsxdrBIG+r7Eq+NPOPAuUMwAOsKzNBcU8K +clq0Jhp/gbzAlYoGh6gRn1xzON9aaUAhKa1GI1Z14emLEBhMe1Q= +-----END CERTIFICATE----- diff --git a/common/src/leap/soledad/common/tests/u1db_tests/testing-certs/testing.cert b/common/src/leap/soledad/common/tests/u1db_tests/testing-certs/testing.cert new file mode 100644 index 00000000..985684fb --- /dev/null +++ b/common/src/leap/soledad/common/tests/u1db_tests/testing-certs/testing.cert @@ -0,0 +1,61 @@ +Certificate: +    Data: +        Version: 3 (0x2) +        Serial Number: +            e4:de:01:76:c4:78:78:7f +    Signature Algorithm: sha1WithRSAEncryption +        Issuer: C=UK, ST=-, O=u1db LOCAL TESTING ONLY, DO NO TRUST, CN=u1db testing CA +        Validity +            Not Before: May  3 11:11:14 2012 GMT +            Not After : May  1 11:11:14 2023 GMT +        Subject: O=u1db LOCAL TESTING ONLY, DO NOT TRUST, CN=localhost +        Subject Public Key Info: +            Public Key Algorithm: rsaEncryption +                Public-Key: (1024 bit) +                Modulus: +                    00:c6:1d:72:d3:c5:e4:fc:d1:4c:d9:e4:08:3e:90: +                    10:ce:3f:1f:87:4a:1d:4f:7f:2a:5a:52:c9:65:4f: +                    d9:2c:bf:69:75:18:1a:b5:c9:09:32:00:47:f5:60: +                    aa:c6:dd:3a:87:37:5f:16:be:de:29:b5:ea:fc:41: +                    7e:eb:77:bb:df:63:c3:06:1e:ed:e9:a0:67:1a:f1: +                    ec:e1:9d:f7:9c:8f:1c:fa:c3:66:7b:39:dc:70:ae: +                    09:1b:9c:c0:9a:c4:90:77:45:8e:39:95:a9:2f:92: +                    43:bd:27:07:5a:99:51:6e:76:a0:af:dd:b1:2c:8f: +                    ca:8b:8c:47:0d:f6:6e:fc:69 +                Exponent: 65537 (0x10001) +        X509v3 extensions: +            X509v3 Basic Constraints:  +                CA:FALSE +            Netscape Comment:  +                OpenSSL Generated Certificate +            X509v3 Subject Key Identifier:  +                1C:63:85:E1:1D:F3:89:2E:6C:4E:3F:FB:D0:10:64:5A:C1:22:6A:2A +            X509v3 Authority Key Identifier:  +                keyid:DB:3D:93:51:6C:32:15:54:8F:10:50:FC:49:4F:36:15:28:BB:95:6D + +    Signature Algorithm: sha1WithRSAEncryption +         1d:6d:3e:bd:93:fd:bd:3e:17:b8:9f:f0:99:7f:db:50:5c:b2: +         01:42:03:b5:d5:94:05:d3:f6:8e:80:82:55:47:1f:58:f2:18: +         6c:ab:ef:43:2c:2f:10:e1:7c:c4:5c:cc:ac:50:50:22:42:aa: +         35:33:f5:b9:f3:a6:66:55:d9:36:f4:f2:e4:d4:d9:b5:2c:52: +         66:d4:21:17:97:22:b8:9b:d7:0e:7c:3d:ce:85:19:ca:c4:d2: +         58:62:31:c6:18:3e:44:fc:f4:30:b6:95:87:ee:21:4a:08:f0: +         af:3c:8f:c4:ba:5e:a1:5c:37:1a:7d:7b:fe:66:ae:62:50:17: +         31:ca +-----BEGIN CERTIFICATE----- +MIICnzCCAgigAwIBAgIJAOTeAXbEeHh/MA0GCSqGSIb3DQEBBQUAMGIxCzAJBgNV +BAYTAlVLMQowCAYDVQQIDAEtMS0wKwYDVQQKDCR1MWRiIExPQ0FMIFRFU1RJTkcg +T05MWSwgRE8gTk8gVFJVU1QxGDAWBgNVBAMMD3UxZGIgdGVzdGluZyBDQTAeFw0x +MjA1MDMxMTExMTRaFw0yMzA1MDExMTExMTRaMEQxLjAsBgNVBAoMJXUxZGIgTE9D +QUwgVEVTVElORyBPTkxZLCBETyBOT1QgVFJVU1QxEjAQBgNVBAMMCWxvY2FsaG9z +dDCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAxh1y08Xk/NFM2eQIPpAQzj8f +h0odT38qWlLJZU/ZLL9pdRgatckJMgBH9WCqxt06hzdfFr7eKbXq/EF+63e732PD +Bh7t6aBnGvHs4Z33nI8c+sNmeznccK4JG5zAmsSQd0WOOZWpL5JDvScHWplRbnag +r92xLI/Ki4xHDfZu/GkCAwEAAaN7MHkwCQYDVR0TBAIwADAsBglghkgBhvhCAQ0E +HxYdT3BlblNTTCBHZW5lcmF0ZWQgQ2VydGlmaWNhdGUwHQYDVR0OBBYEFBxjheEd +84kubE4/+9AQZFrBImoqMB8GA1UdIwQYMBaAFNs9k1FsMhVUjxBQ/ElPNhUou5Vt +MA0GCSqGSIb3DQEBBQUAA4GBAB1tPr2T/b0+F7if8Jl/21BcsgFCA7XVlAXT9o6A +glVHH1jyGGyr70MsLxDhfMRczKxQUCJCqjUz9bnzpmZV2Tb08uTU2bUsUmbUIReX +Irib1w58Pc6FGcrE0lhiMcYYPkT89DC2lYfuIUoI8K88j8S6XqFcNxp9e/5mrmJQ +FzHK +-----END CERTIFICATE----- diff --git a/common/src/leap/soledad/common/tests/u1db_tests/testing-certs/testing.key b/common/src/leap/soledad/common/tests/u1db_tests/testing-certs/testing.key new file mode 100644 index 00000000..d83d4920 --- /dev/null +++ b/common/src/leap/soledad/common/tests/u1db_tests/testing-certs/testing.key @@ -0,0 +1,16 @@ +-----BEGIN PRIVATE KEY----- +MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAMYdctPF5PzRTNnk +CD6QEM4/H4dKHU9/KlpSyWVP2Sy/aXUYGrXJCTIAR/VgqsbdOoc3Xxa+3im16vxB +fut3u99jwwYe7emgZxrx7OGd95yPHPrDZns53HCuCRucwJrEkHdFjjmVqS+SQ70n +B1qZUW52oK/dsSyPyouMRw32bvxpAgMBAAECgYBs3lXxhjg1rhabTjIxnx19GTcM +M3Az9V+izweZQu3HJ1CeZiaXauhAr+LbNsniCkRVddotN6oCJdQB10QVxXBZc9Jz +HPJ4zxtZfRZlNMTMmG7eLWrfxpgWnb/BUjDb40yy1nhr9yhDUnI/8RoHDRHnAEHZ +/CnHGUrqcVcrY5zJAQJBAPLhBJg9W88JVmcOKdWxRgs7dLHnZb999Kv1V5mczmAi +jvGvbUmucqOqke6pTUHNYyNHqU6pySzGUi2cH+BAkFECQQDQ0VoAOysg6FVoT15v +tGh57t5sTiCZZ7PS8jwvtThsgA+vcf6c16XWzXgjGXSap4r2QDOY2rI5lsWLaQ8T ++fyZAkAfyFJRmbXp4c7srW3MCOahkaYzoZQu+syJtBFCiMJ40gzik5I5khpuUGPI +V19EvRu8AiSlppIsycb3MPb64XgBAkEAy7DrUf5le5wmc7G4NM6OeyJ+5LbxJbL6 +vnJ8My1a9LuWkVVpQCU7J+UVo2dZTuLPspW9vwTVhUeFOxAoHRxlQQJAFem93f7m +el2BkB2EFqU3onPejkZ5UrDmfmeOQR1axMQNSXqSxcJxqa16Ru1BWV2gcWRbwajQ +oc+kuJThu/r/Ug== +-----END PRIVATE KEY----- | 
