diff options
-rwxr-xr-x | jenkins.sh | 2 | ||||
-rw-r--r-- | synapse/api/auth.py | 57 | ||||
-rw-r--r-- | synapse/federation/federation_server.py | 15 | ||||
-rw-r--r-- | synapse/federation/transport/server.py | 12 | ||||
-rw-r--r-- | synapse/handlers/_base.py | 53 | ||||
-rw-r--r-- | synapse/handlers/federation.py | 93 | ||||
-rw-r--r-- | synapse/handlers/presence.py | 9 | ||||
-rw-r--r-- | synapse/handlers/room.py | 67 | ||||
-rw-r--r-- | synapse/handlers/sync.py | 8 | ||||
-rw-r--r-- | synapse/push/bulk_push_rule_evaluator.py | 2 | ||||
-rw-r--r-- | synapse/python_dependencies.py | 2 | ||||
-rw-r--r-- | synapse/rest/client/v1/room.py | 4 | ||||
-rw-r--r-- | tests/api/test_filtering.py | 18 | ||||
-rw-r--r-- | tests/config/test_load.py | 21 | ||||
-rw-r--r-- | tests/metrics/test_metric.py | 3 | ||||
-rw-r--r-- | tests/util/test_treecache.py | 6 |
16 files changed, 297 insertions, 75 deletions
diff --git a/jenkins.sh b/jenkins.sh index ed3bdf80fa..359fd22771 100755 --- a/jenkins.sh +++ b/jenkins.sh @@ -1,5 +1,7 @@ #!/bin/bash -eu +: ${WORKSPACE:="$(pwd)"} + export PYTHONDONTWRITEBYTECODE=yep # Output test results as junit xml diff --git a/synapse/api/auth.py b/synapse/api/auth.py index e2f84c4d57..183245443c 100644 --- a/synapse/api/auth.py +++ b/synapse/api/auth.py @@ -434,31 +434,46 @@ class Auth(object): if event.user_id != invite_event.user_id: return False - try: - public_key = invite_event.content["public_key"] - if signed["mxid"] != event.state_key: - return False - if signed["token"] != token: - return False - for server, signature_block in signed["signatures"].items(): - for key_name, encoded_signature in signature_block.items(): - if not key_name.startswith("ed25519:"): - return False - verify_key = decode_verify_key_bytes( - key_name, - decode_base64(public_key) - ) - verify_signed_json(signed, server, verify_key) - # We got the public key from the invite, so we know that the - # correct server signed the signed bundle. - # The caller is responsible for checking that the signing - # server has not revoked that public key. - return True + if signed["mxid"] != event.state_key: return False - except (KeyError, SignatureVerifyException,): + if signed["token"] != token: return False + for public_key_object in self.get_public_keys(invite_event): + public_key = public_key_object["public_key"] + try: + for server, signature_block in signed["signatures"].items(): + for key_name, encoded_signature in signature_block.items(): + if not key_name.startswith("ed25519:"): + continue + verify_key = decode_verify_key_bytes( + key_name, + decode_base64(public_key) + ) + verify_signed_json(signed, server, verify_key) + + # We got the public key from the invite, so we know that the + # correct server signed the signed bundle. + # The caller is responsible for checking that the signing + # server has not revoked that public key. + return True + except (KeyError, SignatureVerifyException,): + continue + return False + + def get_public_keys(self, invite_event): + public_keys = [] + if "public_key" in invite_event.content: + o = { + "public_key": invite_event.content["public_key"], + } + if "key_validity_url" in invite_event.content: + o["key_validity_url"] = invite_event.content["key_validity_url"] + public_keys.append(o) + public_keys.extend(invite_event.content.get("public_keys", [])) + return public_keys + def _get_power_level_event(self, auth_events): key = (EventTypes.PowerLevels, "", ) return auth_events.get(key) diff --git a/synapse/federation/federation_server.py b/synapse/federation/federation_server.py index 90718192dd..e8bfbe7cb5 100644 --- a/synapse/federation/federation_server.py +++ b/synapse/federation/federation_server.py @@ -543,8 +543,19 @@ class FederationServer(FederationBase): return event @defer.inlineCallbacks - def exchange_third_party_invite(self, invite): - ret = yield self.handler.exchange_third_party_invite(invite) + def exchange_third_party_invite( + self, + sender_user_id, + target_user_id, + room_id, + signed, + ): + ret = yield self.handler.exchange_third_party_invite( + sender_user_id, + target_user_id, + room_id, + signed, + ) defer.returnValue(ret) @defer.inlineCallbacks diff --git a/synapse/federation/transport/server.py b/synapse/federation/transport/server.py index 65e054f7dd..6e92e2f8f4 100644 --- a/synapse/federation/transport/server.py +++ b/synapse/federation/transport/server.py @@ -425,7 +425,17 @@ class On3pidBindServlet(BaseFederationServlet): last_exception = None for invite in content["invites"]: try: - yield self.handler.exchange_third_party_invite(invite) + if "signed" not in invite or "token" not in invite["signed"]: + message = ("Rejecting received notification of third-" + "party invite without signed: %s" % (invite,)) + logger.info(message) + raise SynapseError(400, message) + yield self.handler.exchange_third_party_invite( + invite["sender"], + invite["mxid"], + invite["room_id"], + invite["signed"], + ) except Exception as e: last_exception = e if last_exception: diff --git a/synapse/handlers/_base.py b/synapse/handlers/_base.py index a516a84a66..5613bd2059 100644 --- a/synapse/handlers/_base.py +++ b/synapse/handlers/_base.py @@ -53,9 +53,15 @@ class BaseHandler(object): self.event_builder_factory = hs.get_event_builder_factory() @defer.inlineCallbacks - def _filter_events_for_clients(self, user_tuples, events, event_id_to_state): + def filter_events_for_clients(self, user_tuples, events, event_id_to_state): """ Returns dict of user_id -> list of events that user is allowed to see. + + :param (str, bool) user_tuples: (user id, is_peeking) for each + user to be checked. is_peeking should be true if: + * the user is not currently a member of the room, and: + * the user has not been a member of the room since the given + events """ forgotten = yield defer.gatherResults([ self.store.who_forgot_in_room( @@ -72,18 +78,20 @@ class BaseHandler(object): def allowed(event, user_id, is_peeking): state = event_id_to_state[event.event_id] + # get the room_visibility at the time of the event. visibility_event = state.get((EventTypes.RoomHistoryVisibility, ""), None) if visibility_event: visibility = visibility_event.content.get("history_visibility", "shared") else: visibility = "shared" + # if it was world_readable, it's easy: everyone can read it if visibility == "world_readable": return True - if is_peeking: - return False - + # get the user's membership at the time of the event. (or rather, + # just *after* the event. Which means that people can see their + # own join events, but not (currently) their own leave events.) membership_event = state.get((EventTypes.Member, user_id), None) if membership_event: if membership_event.event_id in event_id_forgotten: @@ -93,20 +101,29 @@ class BaseHandler(object): else: membership = None + # if the user was a member of the room at the time of the event, + # they can see it. if membership == Membership.JOIN: return True - if event.type == EventTypes.RoomHistoryVisibility: - return not is_peeking + if visibility == "joined": + # we weren't a member at the time of the event, so we can't + # see this event. + return False - if visibility == "shared": - return True - elif visibility == "joined": - return membership == Membership.JOIN elif visibility == "invited": + # user can also see the event if they were *invited* at the time + # of the event. return membership == Membership.INVITE - return True + else: + # visibility is shared: user can also see the event if they have + # become a member since the event + # + # XXX: if the user has subsequently joined and then left again, + # ideally we would share history up to the point they left. But + # we don't know when they left. + return not is_peeking defer.returnValue({ user_id: [ @@ -119,7 +136,17 @@ class BaseHandler(object): @defer.inlineCallbacks def _filter_events_for_client(self, user_id, events, is_peeking=False): - # Assumes that user has at some point joined the room if not is_guest. + """ + Check which events a user is allowed to see + + :param str user_id: user id to be checked + :param [synapse.events.EventBase] events: list of events to be checked + :param bool is_peeking should be True if: + * the user is not currently a member of the room, and: + * the user has not been a member of the room since the given + events + :rtype [synapse.events.EventBase] + """ types = ( (EventTypes.RoomHistoryVisibility, ""), (EventTypes.Member, user_id), @@ -128,7 +155,7 @@ class BaseHandler(object): frozenset(e.event_id for e in events), types=types ) - res = yield self._filter_events_for_clients( + res = yield self.filter_events_for_clients( [(user_id, is_peeking)], events, event_id_to_state ) defer.returnValue(res.get(user_id, [])) diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py index ac15f9e5dd..3655b9e5e2 100644 --- a/synapse/handlers/federation.py +++ b/synapse/handlers/federation.py @@ -14,6 +14,9 @@ # limitations under the License. """Contains handlers for federation events.""" +from signedjson.key import decode_verify_key_bytes +from signedjson.sign import verify_signed_json +from unpaddedbase64 import decode_base64 from ._base import BaseHandler @@ -1620,19 +1623,15 @@ class FederationHandler(BaseHandler): @defer.inlineCallbacks @log_function - def exchange_third_party_invite(self, invite): - sender = invite["sender"] - room_id = invite["room_id"] - - if "signed" not in invite or "token" not in invite["signed"]: - logger.info( - "Discarding received notification of third party invite " - "without signed: %s" % (invite,) - ) - return - + def exchange_third_party_invite( + self, + sender_user_id, + target_user_id, + room_id, + signed, + ): third_party_invite = { - "signed": invite["signed"], + "signed": signed, } event_dict = { @@ -1642,8 +1641,8 @@ class FederationHandler(BaseHandler): "third_party_invite": third_party_invite, }, "room_id": room_id, - "sender": sender, - "state_key": invite["mxid"], + "sender": sender_user_id, + "state_key": target_user_id, } if (yield self.auth.check_host_in_room(room_id, self.hs.hostname)): @@ -1656,11 +1655,11 @@ class FederationHandler(BaseHandler): ) self.auth.check(event, context.current_state) - yield self._validate_keyserver(event, auth_events=context.current_state) + yield self._check_signature(event, auth_events=context.current_state) member_handler = self.hs.get_handlers().room_member_handler yield member_handler.send_membership_event(event, context, from_client=False) else: - destinations = set([x.split(":", 1)[-1] for x in (sender, room_id)]) + destinations = set(x.split(":", 1)[-1] for x in (sender_user_id, room_id)) yield self.replication_layer.forward_third_party_invite( destinations, room_id, @@ -1681,7 +1680,7 @@ class FederationHandler(BaseHandler): ) self.auth.check(event, auth_events=context.current_state) - yield self._validate_keyserver(event, auth_events=context.current_state) + yield self._check_signature(event, auth_events=context.current_state) returned_invite = yield self.send_invite(origin, event) # TODO: Make sure the signatures actually are correct. @@ -1711,17 +1710,69 @@ class FederationHandler(BaseHandler): defer.returnValue((event, context)) @defer.inlineCallbacks - def _validate_keyserver(self, event, auth_events): - token = event.content["third_party_invite"]["signed"]["token"] + def _check_signature(self, event, auth_events): + """ + Checks that the signature in the event is consistent with its invite. + :param event (Event): The m.room.member event to check + :param auth_events (dict<(event type, state_key), event>) + + :raises + AuthError if signature didn't match any keys, or key has been + revoked, + SynapseError if a transient error meant a key couldn't be checked + for revocation. + """ + signed = event.content["third_party_invite"]["signed"] + token = signed["token"] invite_event = auth_events.get( (EventTypes.ThirdPartyInvite, token,) ) + if not invite_event: + raise AuthError(403, "Could not find invite") + + last_exception = None + for public_key_object in self.hs.get_auth().get_public_keys(invite_event): + try: + for server, signature_block in signed["signatures"].items(): + for key_name, encoded_signature in signature_block.items(): + if not key_name.startswith("ed25519:"): + continue + + public_key = public_key_object["public_key"] + verify_key = decode_verify_key_bytes( + key_name, + decode_base64(public_key) + ) + verify_signed_json(signed, server, verify_key) + if "key_validity_url" in public_key_object: + yield self._check_key_revocation( + public_key, + public_key_object["key_validity_url"] + ) + return + except Exception as e: + last_exception = e + raise last_exception + + @defer.inlineCallbacks + def _check_key_revocation(self, public_key, url): + """ + Checks whether public_key has been revoked. + + :param public_key (str): base-64 encoded public key. + :param url (str): Key revocation URL. + + :raises + AuthError if they key has been revoked. + SynapseError if a transient error meant a key couldn't be checked + for revocation. + """ try: response = yield self.hs.get_simple_http_client().get_json( - invite_event.content["key_validity_url"], - {"public_key": invite_event.content["public_key"]} + url, + {"public_key": public_key} ) except Exception: raise SynapseError( diff --git a/synapse/handlers/presence.py b/synapse/handlers/presence.py index 952e48e319..08e38cdd25 100644 --- a/synapse/handlers/presence.py +++ b/synapse/handlers/presence.py @@ -525,6 +525,7 @@ class PresenceHandler(BaseHandler): new_fields["last_active_ts"] = now - last_active_ago new_fields["status_msg"] = push.get("status_msg", None) + new_fields["currently_active"] = push.get("currently_active", False) prev_state = yield self.current_state_for_user(user_id) updates.append(prev_state.copy_and_replace(**new_fields)) @@ -853,13 +854,15 @@ class PresenceEventSource(object): else: room_ids = set(room_ids) + max_token = self.store.get_current_presence_token() + plist = yield self.store.get_presence_list_accepted(user.localpart) friends = set(row["observed_user_id"] for row in plist) friends.add(user_id) # So that we receive our own presence user_ids_changed = set() changed = None - if from_key and from_key < 100: + if from_key and max_token - from_key < 100: # For small deltas, its quicker to get all changes and then # work out if we share a room or they're in our presence list changed = stream_change_cache.get_all_entities_changed(from_key) @@ -882,8 +885,7 @@ class PresenceEventSource(object): users = yield self.store.get_users_in_room(room_id) user_ids_to_check.update(users) - plist = yield self.store.get_presence_list_accepted(user.localpart) - user_ids_to_check.update([row["observed_user_id"] for row in plist]) + user_ids_to_check.update(friends) # Always include yourself. Only really matters for when the user is # not in any rooms, but still. @@ -898,7 +900,6 @@ class PresenceEventSource(object): updates = yield presence.current_state_for_users(user_ids_changed) - max_token = self.store.get_current_presence_token() now = self.clock.time_msec() defer.returnValue(([ diff --git a/synapse/handlers/room.py b/synapse/handlers/room.py index b00cac4bd4..eb9700a35b 100644 --- a/synapse/handlers/room.py +++ b/synapse/handlers/room.py @@ -398,6 +398,7 @@ class RoomMemberHandler(BaseHandler): action, txn_id=None, remote_room_hosts=None, + third_party_signed=None, ratelimit=True, ): effective_membership_state = action @@ -406,6 +407,15 @@ class RoomMemberHandler(BaseHandler): elif action == "forget": effective_membership_state = "leave" + if third_party_signed is not None: + replication = self.hs.get_replication_layer() + yield replication.exchange_third_party_invite( + third_party_signed["sender"], + target.to_string(), + room_id, + third_party_signed, + ) + msg_handler = self.hs.get_handlers().message_handler content = {"membership": effective_membership_state} @@ -759,7 +769,7 @@ class RoomMemberHandler(BaseHandler): if room_avatar_event: room_avatar_url = room_avatar_event.content.get("url", "") - token, public_key, key_validity_url, display_name = ( + token, public_keys, fallback_public_key, display_name = ( yield self._ask_id_server_for_third_party_invite( id_server=id_server, medium=medium, @@ -774,14 +784,18 @@ class RoomMemberHandler(BaseHandler): inviter_avatar_url=inviter_avatar_url ) ) + msg_handler = self.hs.get_handlers().message_handler yield msg_handler.create_and_send_nonmember_event( { "type": EventTypes.ThirdPartyInvite, "content": { "display_name": display_name, - "key_validity_url": key_validity_url, - "public_key": public_key, + "public_keys": public_keys, + + # For backwards compatibility: + "key_validity_url": fallback_public_key["key_validity_url"], + "public_key": fallback_public_key["public_key"], }, "room_id": room_id, "sender": user.to_string(), @@ -806,6 +820,34 @@ class RoomMemberHandler(BaseHandler): inviter_display_name, inviter_avatar_url ): + """ + Asks an identity server for a third party invite. + + :param id_server (str): hostname + optional port for the identity server. + :param medium (str): The literal string "email". + :param address (str): The third party address being invited. + :param room_id (str): The ID of the room to which the user is invited. + :param inviter_user_id (str): The user ID of the inviter. + :param room_alias (str): An alias for the room, for cosmetic + notifications. + :param room_avatar_url (str): The URL of the room's avatar, for cosmetic + notifications. + :param room_join_rules (str): The join rules of the email + (e.g. "public"). + :param room_name (str): The m.room.name of the room. + :param inviter_display_name (str): The current display name of the + inviter. + :param inviter_avatar_url (str): The URL of the inviter's avatar. + + :return: A deferred tuple containing: + token (str): The token which must be signed to prove authenticity. + public_keys ([{"public_key": str, "key_validity_url": str}]): + public_key is a base64-encoded ed25519 public key. + fallback_public_key: One element from public_keys. + display_name (str): A user-friendly name to represent the invited + user. + """ + is_url = "%s%s/_matrix/identity/api/v1/store-invite" % ( id_server_scheme, id_server, ) @@ -826,12 +868,21 @@ class RoomMemberHandler(BaseHandler): ) # TODO: Check for success token = data["token"] - public_key = data["public_key"] + public_keys = data.get("public_keys", []) + if "public_key" in data: + fallback_public_key = { + "public_key": data["public_key"], + "key_validity_url": "%s%s/_matrix/identity/api/v1/pubkey/isvalid" % ( + id_server_scheme, id_server, + ), + } + else: + fallback_public_key = public_keys[0] + + if not public_keys: + public_keys.append(fallback_public_key) display_name = data["display_name"] - key_validity_url = "%s%s/_matrix/identity/api/v1/pubkey/isvalid" % ( - id_server_scheme, id_server, - ) - defer.returnValue((token, public_key, key_validity_url, display_name)) + defer.returnValue((token, public_keys, fallback_public_key, display_name)) def forget(self, user, room_id): return self.store.forget(user.to_string(), room_id) diff --git a/synapse/handlers/sync.py b/synapse/handlers/sync.py index 558c7bacb9..fded6e4009 100644 --- a/synapse/handlers/sync.py +++ b/synapse/handlers/sync.py @@ -121,7 +121,11 @@ class SyncResult(collections.namedtuple("SyncResult", [ events. """ return bool( - self.presence or self.joined or self.invited or self.archived + self.presence or + self.joined or + self.invited or + self.archived or + self.account_data ) @@ -645,7 +649,6 @@ class SyncHandler(BaseHandler): recents = yield self._filter_events_for_client( sync_config.user.to_string(), recents, - is_peeking=sync_config.is_guest, ) else: recents = [] @@ -667,7 +670,6 @@ class SyncHandler(BaseHandler): loaded_recents = yield self._filter_events_for_client( sync_config.user.to_string(), loaded_recents, - is_peeking=sync_config.is_guest, ) loaded_recents.extend(recents) recents = loaded_recents diff --git a/synapse/push/bulk_push_rule_evaluator.py b/synapse/push/bulk_push_rule_evaluator.py index 0a23b3f102..5d8be483e5 100644 --- a/synapse/push/bulk_push_rule_evaluator.py +++ b/synapse/push/bulk_push_rule_evaluator.py @@ -103,7 +103,7 @@ class BulkPushRuleEvaluator: users_dict = yield self.store.are_guests(self.rules_by_user.keys()) - filtered_by_user = yield handler._filter_events_for_clients( + filtered_by_user = yield handler.filter_events_for_clients( users_dict.items(), [event], {event.event_id: current_state} ) diff --git a/synapse/python_dependencies.py b/synapse/python_dependencies.py index 75bf3d13aa..35933324a4 100644 --- a/synapse/python_dependencies.py +++ b/synapse/python_dependencies.py @@ -19,7 +19,7 @@ logger = logging.getLogger(__name__) REQUIREMENTS = { "frozendict>=0.4": ["frozendict"], - "unpaddedbase64>=1.0.1": ["unpaddedbase64>=1.0.1"], + "unpaddedbase64>=1.1.0": ["unpaddedbase64>=1.1.0"], "canonicaljson>=1.0.0": ["canonicaljson>=1.0.0"], "signedjson>=1.0.0": ["signedjson>=1.0.0"], "pynacl==0.3.0": ["nacl==0.3.0", "nacl.bindings"], diff --git a/synapse/rest/client/v1/room.py b/synapse/rest/client/v1/room.py index e6f5c5614a..07a2a5dd82 100644 --- a/synapse/rest/client/v1/room.py +++ b/synapse/rest/client/v1/room.py @@ -228,6 +228,8 @@ class JoinRoomAliasServlet(ClientV1RestServlet): allow_guest=True, ) + content = _parse_json(request) + if RoomID.is_valid(room_identifier): room_id = room_identifier remote_room_hosts = None @@ -248,6 +250,7 @@ class JoinRoomAliasServlet(ClientV1RestServlet): action="join", txn_id=txn_id, remote_room_hosts=remote_room_hosts, + third_party_signed=content.get("third_party_signed", None), ) defer.returnValue((200, {"room_id": room_id})) @@ -451,6 +454,7 @@ class RoomMembershipRestServlet(ClientV1RestServlet): room_id=room_id, action=membership_action, txn_id=txn_id, + third_party_signed=content.get("third_party_signed", None), ) defer.returnValue((200, {})) diff --git a/tests/api/test_filtering.py b/tests/api/test_filtering.py index 4297a89f85..dcb6c5bc31 100644 --- a/tests/api/test_filtering.py +++ b/tests/api/test_filtering.py @@ -456,6 +456,22 @@ class FilteringTestCase(unittest.TestCase): results = user_filter.filter_room_state(events) self.assertEquals([], results) + def test_filter_rooms(self): + definition = { + "rooms": ["!allowed:example.com", "!excluded:example.com"], + "not_rooms": ["!excluded:example.com"], + } + + room_ids = [ + "!allowed:example.com", # Allowed because in rooms and not in not_rooms. + "!excluded:example.com", # Disallowed because in not_rooms. + "!not_included:example.com", # Disallowed because not in rooms. + ] + + filtered_room_ids = list(Filter(definition).filter_rooms(room_ids)) + + self.assertEquals(filtered_room_ids, ["!allowed:example.com"]) + @defer.inlineCallbacks def test_add_filter(self): user_filter_json = { @@ -500,3 +516,5 @@ class FilteringTestCase(unittest.TestCase): ) self.assertEquals(filter.get_filter_json(), user_filter_json) + + self.assertRegexpMatches(repr(filter), r"<FilterCollection \{.*\}>") diff --git a/tests/config/test_load.py b/tests/config/test_load.py index fbbbf93fef..bf46233c5c 100644 --- a/tests/config/test_load.py +++ b/tests/config/test_load.py @@ -60,6 +60,22 @@ class ConfigLoadingTestCase(unittest.TestCase): config2 = HomeServerConfig.load_config("", ["-c", self.file]) self.assertEqual(config1.macaroon_secret_key, config2.macaroon_secret_key) + def test_disable_registration(self): + self.generate_config() + self.add_lines_to_config([ + "enable_registration: true", + "disable_registration: true", + ]) + # Check that disable_registration clobbers enable_registration. + config = HomeServerConfig.load_config("", ["-c", self.file]) + self.assertFalse(config.enable_registration) + + # Check that either config value is clobbered by the command line. + config = HomeServerConfig.load_config("", [ + "-c", self.file, "--enable-registration" + ]) + self.assertTrue(config.enable_registration) + def generate_config(self): HomeServerConfig.load_config("", [ "--generate-config", @@ -76,3 +92,8 @@ class ConfigLoadingTestCase(unittest.TestCase): contents = [l for l in contents if needle not in l] with open(self.file, "w") as f: f.write("".join(contents)) + + def add_lines_to_config(self, lines): + with open(self.file, "a") as f: + for line in lines: + f.write(line + "\n") diff --git a/tests/metrics/test_metric.py b/tests/metrics/test_metric.py index f9e5e5af01..f3c1927ce1 100644 --- a/tests/metrics/test_metric.py +++ b/tests/metrics/test_metric.py @@ -61,6 +61,9 @@ class CounterMetricTestCase(unittest.TestCase): 'vector{method="PUT"} 1', ]) + # Check that passing too few values errors + self.assertRaises(ValueError, counter.inc) + class CallbackMetricTestCase(unittest.TestCase): diff --git a/tests/util/test_treecache.py b/tests/util/test_treecache.py index bcc64422b2..7ab578a185 100644 --- a/tests/util/test_treecache.py +++ b/tests/util/test_treecache.py @@ -77,3 +77,9 @@ class TreeCacheTestCase(unittest.TestCase): cache[("b",)] = "B" cache.clear() self.assertEquals(len(cache), 0) + + def test_contains(self): + cache = TreeCache() + cache[("a",)] = "A" + self.assertTrue(("a",) in cache) + self.assertFalse(("b",) in cache) |