summary refs log tree commit diff
diff options
context:
space:
mode:
authorErik Johnston <erik@matrix.org>2014-09-25 17:02:53 +0100
committerErik Johnston <erik@matrix.org>2014-09-25 17:02:53 +0100
commit37bfe44046d46effba5f86b65ab37fa0d0c505b0 (patch)
tree9524fd5e5ac1637982549030a2759aaf538e8ac2
parentfix rst warnings (diff)
parentDon't strip out null's in serialized events, as that is not need anymore and ... (diff)
downloadsynapse-37bfe44046d46effba5f86b65ab37fa0d0c505b0.tar.xz
Merge branch 'deletions' of github.com:matrix-org/synapse into develop
-rw-r--r--synapse/api/auth.py34
-rw-r--r--synapse/api/events/__init__.py7
-rw-r--r--synapse/api/events/factory.py4
-rw-r--r--synapse/api/events/room.py9
-rw-r--r--synapse/api/events/utils.py64
-rw-r--r--synapse/handlers/room.py1
-rw-r--r--synapse/rest/room.py38
-rw-r--r--synapse/storage/__init__.py26
-rw-r--r--synapse/storage/_base.py31
-rw-r--r--synapse/storage/room.py10
-rw-r--r--synapse/storage/roommember.py14
-rw-r--r--synapse/storage/schema/delta/v4.sql12
-rw-r--r--synapse/storage/schema/im.sql3
-rw-r--r--synapse/storage/schema/redactions.sql8
-rw-r--r--synapse/storage/stream.py33
-rw-r--r--tests/storage/test_redaction.py262
-rw-r--r--tests/utils.py2
17 files changed, 531 insertions, 27 deletions
diff --git a/synapse/api/auth.py b/synapse/api/auth.py
index 5321864d51..9bfd25c86e 100644
--- a/synapse/api/auth.py
+++ b/synapse/api/auth.py
@@ -19,7 +19,9 @@ from twisted.internet import defer
 
 from synapse.api.constants import Membership, JoinRules
 from synapse.api.errors import AuthError, StoreError, Codes, SynapseError
-from synapse.api.events.room import RoomMemberEvent, RoomPowerLevelsEvent
+from synapse.api.events.room import (
+    RoomMemberEvent, RoomPowerLevelsEvent, RoomRedactionEvent,
+)
 from synapse.util.logutils import log_function
 
 import logging
@@ -70,6 +72,9 @@ class Auth(object):
                 if event.type == RoomPowerLevelsEvent.TYPE:
                     yield self._check_power_levels(event)
 
+                if event.type == RoomRedactionEvent.TYPE:
+                    yield self._check_redaction(event)
+
                 defer.returnValue(True)
             else:
                 raise AuthError(500, "Unknown event: %s" % event)
@@ -170,7 +175,7 @@ class Auth(object):
                     event.room_id,
                     event.user_id,
                 )
-                _, kick_level = yield self.store.get_ops_levels(event.room_id)
+                _, kick_level, _ = yield self.store.get_ops_levels(event.room_id)
 
                 if kick_level:
                     kick_level = int(kick_level)
@@ -187,7 +192,7 @@ class Auth(object):
                 event.user_id,
             )
 
-            ban_level, _ = yield self.store.get_ops_levels(event.room_id)
+            ban_level, _, _  = yield self.store.get_ops_levels(event.room_id)
 
             if ban_level:
                 ban_level = int(ban_level)
@@ -322,6 +327,29 @@ class Auth(object):
                 )
 
     @defer.inlineCallbacks
+    def _check_redaction(self, event):
+        user_level = yield self.store.get_power_level(
+            event.room_id,
+            event.user_id,
+        )
+
+        if user_level:
+            user_level = int(user_level)
+        else:
+            user_level = 0
+
+        _, _, redact_level  = yield self.store.get_ops_levels(event.room_id)
+
+        if not redact_level:
+            redact_level = 50
+
+        if user_level < redact_level:
+            raise AuthError(
+                403,
+                "You don't have permission to redact events"
+            )
+
+    @defer.inlineCallbacks
     def _check_power_levels(self, event):
         for k, v in event.content.items():
             if k == "default":
diff --git a/synapse/api/events/__init__.py b/synapse/api/events/__init__.py
index 0cee196851..f66fea2904 100644
--- a/synapse/api/events/__init__.py
+++ b/synapse/api/events/__init__.py
@@ -22,7 +22,8 @@ def serialize_event(hs, e):
     if not isinstance(e, SynapseEvent):
         return e
 
-    d = e.get_dict()
+    # Should this strip out None's?
+    d = {k: v for k, v in e.get_dict().items()}
     if "age_ts" in d:
         d["age"] = int(hs.get_clock().time_msec()) - d["age_ts"]
         del d["age_ts"]
@@ -58,17 +59,19 @@ class SynapseEvent(JsonEncodedObject):
         "required_power_level",
         "age_ts",
         "prev_content",
+        "prev_state",
+        "redacted_because",
     ]
 
     internal_keys = [
         "is_state",
         "prev_events",
-        "prev_state",
         "depth",
         "destinations",
         "origin",
         "outlier",
         "power_level",
+        "redacted",
     ]
 
     required_keys = [
diff --git a/synapse/api/events/factory.py b/synapse/api/events/factory.py
index d3d96d73eb..0d94850cec 100644
--- a/synapse/api/events/factory.py
+++ b/synapse/api/events/factory.py
@@ -17,7 +17,8 @@ from synapse.api.events.room import (
     RoomTopicEvent, MessageEvent, RoomMemberEvent, FeedbackEvent,
     InviteJoinEvent, RoomConfigEvent, RoomNameEvent, GenericEvent,
     RoomPowerLevelsEvent, RoomJoinRulesEvent, RoomOpsPowerLevelsEvent,
-    RoomCreateEvent, RoomAddStateLevelEvent, RoomSendEventLevelEvent
+    RoomCreateEvent, RoomAddStateLevelEvent, RoomSendEventLevelEvent,
+    RoomRedactionEvent,
 )
 
 from synapse.util.stringutils import random_string
@@ -39,6 +40,7 @@ class EventFactory(object):
         RoomAddStateLevelEvent,
         RoomSendEventLevelEvent,
         RoomOpsPowerLevelsEvent,
+        RoomRedactionEvent,
     ]
 
     def __init__(self, hs):
diff --git a/synapse/api/events/room.py b/synapse/api/events/room.py
index 3a4dbc58ce..cd936074fc 100644
--- a/synapse/api/events/room.py
+++ b/synapse/api/events/room.py
@@ -180,3 +180,12 @@ class RoomAliasesEvent(SynapseStateEvent):
 
     def get_content_template(self):
         return {}
+
+
+class RoomRedactionEvent(SynapseEvent):
+    TYPE = "m.room.redaction"
+
+    valid_keys = SynapseEvent.valid_keys + ["redacts"]
+
+    def get_content_template(self):
+        return {}
diff --git a/synapse/api/events/utils.py b/synapse/api/events/utils.py
new file mode 100644
index 0000000000..c3a32be8c1
--- /dev/null
+++ b/synapse/api/events/utils.py
@@ -0,0 +1,64 @@
+# -*- coding: utf-8 -*-
+# Copyright 2014 OpenMarket Ltd
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from .room import (
+    RoomMemberEvent, RoomJoinRulesEvent, RoomPowerLevelsEvent,
+    RoomAddStateLevelEvent, RoomSendEventLevelEvent, RoomOpsPowerLevelsEvent,
+    RoomAliasesEvent, RoomCreateEvent,
+)
+
+def prune_event(event):
+    """ Prunes the given event of all keys we don't know about or think could
+    potentially be dodgy.
+
+    This is used when we "redact" an event. We want to remove all fields that
+    the user has specified, but we do want to keep necessary information like
+    type, state_key etc.
+    """
+
+    # Remove all extraneous fields.
+    event.unrecognized_keys = {}
+
+    new_content = {}
+
+    def add_fields(*fields):
+        for field in fields:
+            if field in event.content:
+                new_content[field] = event.content[field]
+
+    if event.type == RoomMemberEvent.TYPE:
+        add_fields("membership")
+    elif event.type == RoomCreateEvent.TYPE:
+        add_fields("creator")
+    elif event.type == RoomJoinRulesEvent.TYPE:
+        add_fields("join_rule")
+    elif event.type == RoomPowerLevelsEvent.TYPE:
+        # TODO: Actually check these are valid user_ids etc.
+        add_fields("default")
+        for k, v in event.content.items():
+            if k.startswith("@") and isinstance(v, (int, long)):
+                new_content[k] = v
+    elif event.type == RoomAddStateLevelEvent.TYPE:
+        add_fields("level")
+    elif event.type == RoomSendEventLevelEvent.TYPE:
+        add_fields("level")
+    elif event.type == RoomOpsPowerLevelsEvent.TYPE:
+        add_fields("kick_level", "ban_level", "redact_level")
+    elif event.type == RoomAliasesEvent.TYPE:
+        add_fields("aliases")
+
+    event.content = new_content
+
+    return event
diff --git a/synapse/handlers/room.py b/synapse/handlers/room.py
index 4d8727e8af..c0f9a7c807 100644
--- a/synapse/handlers/room.py
+++ b/synapse/handlers/room.py
@@ -244,6 +244,7 @@ class RoomCreationHandler(BaseHandler):
             etype=RoomOpsPowerLevelsEvent.TYPE,
             ban_level=50,
             kick_level=50,
+            redact_level=50,
         )
 
         return [
diff --git a/synapse/rest/room.py b/synapse/rest/room.py
index cf2e7af2e4..a01dab1b8e 100644
--- a/synapse/rest/room.py
+++ b/synapse/rest/room.py
@@ -19,7 +19,7 @@ from twisted.internet import defer
 from base import RestServlet, client_path_pattern
 from synapse.api.errors import SynapseError, Codes
 from synapse.streams.config import PaginationConfig
-from synapse.api.events.room import RoomMemberEvent
+from synapse.api.events.room import RoomMemberEvent, RoomRedactionEvent
 from synapse.api.constants import Membership
 
 import json
@@ -431,6 +431,41 @@ class RoomMembershipRestServlet(RestServlet):
         self.txns.store_client_transaction(request, txn_id, response)
         defer.returnValue(response)
 
+class RoomRedactEventRestServlet(RestServlet):
+    def register(self, http_server):
+        PATTERN = ("/rooms/(?P<room_id>[^/]*)/redact/(?P<event_id>[^/]*)")
+        register_txn_path(self, PATTERN, http_server)
+
+    @defer.inlineCallbacks
+    def on_POST(self, request, room_id, event_id):
+        user = yield self.auth.get_user_by_req(request)
+        content = _parse_json(request)
+
+        event = self.event_factory.create_event(
+            etype=RoomRedactionEvent.TYPE,
+            room_id=urllib.unquote(room_id),
+            user_id=user.to_string(),
+            content=content,
+            redacts=event_id,
+        )
+
+        msg_handler = self.handlers.message_handler
+        yield msg_handler.send_message(event)
+
+        defer.returnValue((200, {"event_id": event.event_id}))
+
+    @defer.inlineCallbacks
+    def on_PUT(self, request, room_id, event_id, txn_id):
+        try:
+            defer.returnValue(self.txns.get_client_transaction(request, txn_id))
+        except KeyError:
+            pass
+
+        response = yield self.on_POST(request, room_id, event_id)
+
+        self.txns.store_client_transaction(request, txn_id, response)
+        defer.returnValue(response)
+
 
 def _parse_json(request):
     try:
@@ -486,3 +521,4 @@ def register_servlets(hs, http_server):
     PublicRoomListRestServlet(hs).register(http_server)
     RoomStateRestServlet(hs).register(http_server)
     RoomInitialSyncRestServlet(hs).register(http_server)
+    RoomRedactEventRestServlet(hs).register(http_server)
diff --git a/synapse/storage/__init__.py b/synapse/storage/__init__.py
index 1aaef3f493..15919eb580 100644
--- a/synapse/storage/__init__.py
+++ b/synapse/storage/__init__.py
@@ -24,6 +24,7 @@ from synapse.api.events.room import (
     RoomAddStateLevelEvent,
     RoomSendEventLevelEvent,
     RoomOpsPowerLevelsEvent,
+    RoomRedactionEvent,
 )
 
 from synapse.util.logutils import log_function
@@ -56,12 +57,13 @@ SCHEMAS = [
     "presence",
     "im",
     "room_aliases",
+    "redactions",
 ]
 
 
 # Remember to update this number every time an incompatible change is made to
 # database schema files, so the users will be informed on server restarts.
-SCHEMA_VERSION = 3
+SCHEMA_VERSION = 4
 
 
 class _RollbackButIsFineException(Exception):
@@ -182,6 +184,8 @@ class DataStore(RoomMemberStore, RoomStore,
             self._store_send_event_level(txn, event)
         elif event.type == RoomOpsPowerLevelsEvent.TYPE:
             self._store_ops_level(txn, event)
+        elif event.type == RoomRedactionEvent.TYPE:
+            self._store_redaction(txn, event)
 
         vals = {
             "topological_ordering": event.depth,
@@ -203,7 +207,7 @@ class DataStore(RoomMemberStore, RoomStore,
         unrec = {
             k: v
             for k, v in event.get_full_dict().items()
-            if k not in vals.keys()
+            if k not in vals.keys() and k not in ["redacted", "redacted_because"]
         }
         vals["unrecognized_keys"] = json.dumps(unrec)
 
@@ -242,14 +246,28 @@ class DataStore(RoomMemberStore, RoomStore,
                 }
             )
 
+    def _store_redaction(self, txn, event):
+        txn.execute(
+            "INSERT OR IGNORE INTO redactions "
+            "(event_id, redacts) VALUES (?,?)",
+            (event.event_id, event.redacts)
+        )
+
     @defer.inlineCallbacks
     def get_current_state(self, room_id, event_type=None, state_key=""):
+        del_sql = (
+            "SELECT event_id FROM redactions WHERE redacts = e.event_id "
+            "LIMIT 1"
+        )
+
         sql = (
-            "SELECT e.* FROM events as e "
+            "SELECT e.*, (%(redacted)s) AS redacted FROM events as e "
             "INNER JOIN current_state_events as c ON e.event_id = c.event_id "
             "INNER JOIN state_events as s ON e.event_id = s.event_id "
             "WHERE c.room_id = ? "
-        )
+        ) % {
+            "redacted": del_sql,
+        }
 
         if event_type:
             sql += " AND s.type = ? AND s.state_key = ? "
diff --git a/synapse/storage/_base.py b/synapse/storage/_base.py
index 76ed7d06fb..889de2bedc 100644
--- a/synapse/storage/_base.py
+++ b/synapse/storage/_base.py
@@ -17,6 +17,7 @@ import logging
 from twisted.internet import defer
 
 from synapse.api.errors import StoreError
+from synapse.api.events.utils import prune_event
 from synapse.util.logutils import log_function
 
 import collections
@@ -345,7 +346,7 @@ class SQLBaseStore(object):
         return self.runInteraction(func)
 
     def _parse_event_from_row(self, row_dict):
-        d = copy.deepcopy({k: v for k, v in row_dict.items() if v})
+        d = copy.deepcopy({k: v for k, v in row_dict.items()})
 
         d.pop("stream_ordering", None)
         d.pop("topological_ordering", None)
@@ -373,8 +374,8 @@ class SQLBaseStore(object):
         sql = "SELECT * FROM events WHERE event_id = ?"
 
         for ev in events:
-           if hasattr(ev, "prev_state"):
-                # Load previous state_content. 
+            if hasattr(ev, "prev_state"):
+                # Load previous state_content.
                 # TODO: Should we be pulling this out above?
                 cursor = txn.execute(sql, (ev.prev_state,))
                 prevs = self.cursor_to_dict(cursor)
@@ -382,8 +383,32 @@ class SQLBaseStore(object):
                     prev = self._parse_event_from_row(prevs[0])
                     ev.prev_content = prev.content
 
+            if not hasattr(ev, "redacted"):
+                logger.debug("Doesn't have redacted key: %s", ev)
+                ev.redacted = self._has_been_redacted_txn(txn, ev)
+
+            if ev.redacted:
+                # Get the redaction event.
+                sql = "SELECT * FROM events WHERE event_id = ?"
+                txn.execute(sql, (ev.redacted,))
+
+                del_evs = self._parse_events_txn(
+                    txn, self.cursor_to_dict(txn)
+                )
+
+                if del_evs:
+                    prune_event(ev)
+                    ev.redacted_because = del_evs[0]
+
         return events
 
+    def _has_been_redacted_txn(self, txn, event):
+        sql = "SELECT event_id FROM redactions WHERE redacts = ?"
+        txn.execute(sql, (event.event_id,))
+        result = txn.fetchone()
+        return result[0] if result else None
+
+
 class Table(object):
     """ A base class used to store information about a particular table.
     """
diff --git a/synapse/storage/room.py b/synapse/storage/room.py
index 5adf8cdf1b..8cd46334cf 100644
--- a/synapse/storage/room.py
+++ b/synapse/storage/room.py
@@ -27,7 +27,7 @@ import logging
 logger = logging.getLogger(__name__)
 
 
-OpsLevel = collections.namedtuple("OpsLevel", ("ban_level", "kick_level"))
+OpsLevel = collections.namedtuple("OpsLevel", ("ban_level", "kick_level", "redact_level"))
 
 
 class RoomStore(SQLBaseStore):
@@ -189,7 +189,8 @@ class RoomStore(SQLBaseStore):
 
     def _get_ops_levels(self, txn, room_id):
         sql = (
-            "SELECT ban_level, kick_level FROM room_ops_levels as r "
+            "SELECT ban_level, kick_level, redact_level "
+            "FROM room_ops_levels as r "
             "INNER JOIN current_state_events as c "
             "ON r.event_id = c.event_id "
             "WHERE c.room_id = ? "
@@ -198,7 +199,7 @@ class RoomStore(SQLBaseStore):
         rows = txn.execute(sql, (room_id,)).fetchall()
 
         if len(rows) == 1:
-            return OpsLevel(rows[0][0], rows[0][1])
+            return OpsLevel(rows[0][0], rows[0][1], rows[0][2])
         else:
             return OpsLevel(None, None)
 
@@ -326,6 +327,9 @@ class RoomStore(SQLBaseStore):
         if "ban_level" in event.content:
             content["ban_level"] = event.content["ban_level"]
 
+        if "redact_level" in event.content:
+            content["redact_level"] = event.content["redact_level"]
+
         self._simple_insert_txn(
             txn,
             "room_ops_levels",
diff --git a/synapse/storage/roommember.py b/synapse/storage/roommember.py
index 04b4067d03..958e730591 100644
--- a/synapse/storage/roommember.py
+++ b/synapse/storage/roommember.py
@@ -182,14 +182,22 @@ class RoomMemberStore(SQLBaseStore):
         )
 
     def _get_members_query_txn(self, txn, where_clause, where_values):
+        del_sql = (
+            "SELECT event_id FROM redactions WHERE redacts = e.event_id "
+            "LIMIT 1"
+        )
+
         sql = (
-            "SELECT e.* FROM events as e "
+            "SELECT e.*, (%(redacted)s) AS redacted FROM events as e "
             "INNER JOIN room_memberships as m "
             "ON e.event_id = m.event_id "
             "INNER JOIN current_state_events as c "
             "ON m.event_id = c.event_id "
-            "WHERE %s "
-        ) % (where_clause,)
+            "WHERE %(where)s "
+        ) % {
+            "redacted": del_sql,
+            "where": where_clause,
+        }
 
         txn.execute(sql, where_values)
         rows = self.cursor_to_dict(txn)
diff --git a/synapse/storage/schema/delta/v4.sql b/synapse/storage/schema/delta/v4.sql
new file mode 100644
index 0000000000..25d2ead450
--- /dev/null
+++ b/synapse/storage/schema/delta/v4.sql
@@ -0,0 +1,12 @@
+CREATE TABLE IF NOT EXISTS redactions (
+    event_id TEXT NOT NULL,
+    redacts TEXT NOT NULL,
+    CONSTRAINT ev_uniq UNIQUE (event_id)
+);
+
+CREATE INDEX IF NOT EXISTS redactions_event_id ON redactions (event_id);
+CREATE INDEX IF NOT EXISTS redactions_redacts ON redactions (redacts);
+
+ALTER TABLE room_ops_levels ADD COLUMN redact_level INTEGER;
+
+PRAGMA user_version = 4;
diff --git a/synapse/storage/schema/im.sql b/synapse/storage/schema/im.sql
index 6ffea51310..3aa83f5c8c 100644
--- a/synapse/storage/schema/im.sql
+++ b/synapse/storage/schema/im.sql
@@ -150,7 +150,8 @@ CREATE TABLE IF NOT EXISTS room_ops_levels(
     event_id TEXT NOT NULL,
     room_id TEXT NOT NULL,
     ban_level INTEGER,
-    kick_level INTEGER
+    kick_level INTEGER,
+    redact_level INTEGER
 );
 
 CREATE INDEX IF NOT EXISTS room_ops_levels_event_id ON room_ops_levels(event_id);
diff --git a/synapse/storage/schema/redactions.sql b/synapse/storage/schema/redactions.sql
new file mode 100644
index 0000000000..4c2829d05d
--- /dev/null
+++ b/synapse/storage/schema/redactions.sql
@@ -0,0 +1,8 @@
+CREATE TABLE IF NOT EXISTS redactions (
+    event_id TEXT NOT NULL,
+    redacts TEXT NOT NULL,
+    CONSTRAINT ev_uniq UNIQUE (event_id)
+);
+
+CREATE INDEX IF NOT EXISTS redactions_event_id ON redactions (event_id);
+CREATE INDEX IF NOT EXISTS redactions_redacts ON redactions (redacts);
diff --git a/synapse/storage/stream.py b/synapse/storage/stream.py
index a76fecf24f..d61f909939 100644
--- a/synapse/storage/stream.py
+++ b/synapse/storage/stream.py
@@ -157,6 +157,11 @@ class StreamStore(SQLBaseStore):
             "WHERE m.user_id = ? "
         )
 
+        del_sql = (
+            "SELECT event_id FROM redactions WHERE redacts = e.event_id "
+            "LIMIT 1"
+        )
+
         if limit:
             limit = max(limit, MAX_STREAM_SIZE)
         else:
@@ -171,13 +176,14 @@ class StreamStore(SQLBaseStore):
             return
 
         sql = (
-            "SELECT * FROM events as e WHERE "
+            "SELECT *, (%(redacted)s) AS redacted FROM events AS e WHERE "
             "((room_id IN (%(current)s)) OR "
             "(event_id IN (%(invites)s))) "
             "AND e.stream_ordering > ? AND e.stream_ordering <= ? "
             "AND e.outlier = 0 "
             "ORDER BY stream_ordering ASC LIMIT %(limit)d "
         ) % {
+            "redacted": del_sql,
             "current": current_room_membership_sql,
             "invites": membership_sql,
             "limit": limit
@@ -224,11 +230,21 @@ class StreamStore(SQLBaseStore):
         else:
             limit_str = ""
 
+        del_sql = (
+            "SELECT event_id FROM redactions WHERE redacts = events.event_id "
+            "LIMIT 1"
+        )
+
         sql = (
-            "SELECT * FROM events "
+            "SELECT *, (%(redacted)s) AS redacted FROM events "
             "WHERE outlier = 0 AND room_id = ? AND %(bounds)s "
             "ORDER BY topological_ordering %(order)s, stream_ordering %(order)s %(limit)s "
-        ) % {"bounds": bounds, "order": order, "limit": limit_str}
+        ) % {
+            "redacted": del_sql,
+            "bounds": bounds,
+            "order": order,
+            "limit": limit_str
+        }
 
         rows = yield self._execute_and_decode(
             sql,
@@ -257,11 +273,18 @@ class StreamStore(SQLBaseStore):
                                    with_feedback=False):
         # TODO (erikj): Handle compressed feedback
 
+        del_sql = (
+            "SELECT event_id FROM redactions WHERE redacts = events.event_id "
+            "LIMIT 1"
+        )
+
         sql = (
-            "SELECT * FROM events "
+            "SELECT *, (%(redacted)s) AS redacted FROM events "
             "WHERE room_id = ? AND stream_ordering <= ? "
             "ORDER BY topological_ordering DESC, stream_ordering DESC LIMIT ? "
-        )
+        ) % {
+            "redacted": del_sql,
+        }
 
         rows = yield self._execute_and_decode(
             sql,
diff --git a/tests/storage/test_redaction.py b/tests/storage/test_redaction.py
new file mode 100644
index 0000000000..dae1641ea1
--- /dev/null
+++ b/tests/storage/test_redaction.py
@@ -0,0 +1,262 @@
+# -*- coding: utf-8 -*-
+# Copyright 2014 OpenMarket Ltd
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+from tests import unittest
+from twisted.internet import defer
+
+from synapse.server import HomeServer
+from synapse.api.constants import Membership
+from synapse.api.events.room import (
+    RoomMemberEvent, MessageEvent, RoomRedactionEvent,
+)
+
+from tests.utils import SQLiteMemoryDbPool
+
+
+class RedactionTestCase(unittest.TestCase):
+
+    @defer.inlineCallbacks
+    def setUp(self):
+        db_pool = SQLiteMemoryDbPool()
+        yield db_pool.prepare()
+
+        hs = HomeServer(
+            "test",
+            db_pool=db_pool,
+        )
+
+        self.store = hs.get_datastore()
+        self.event_factory = hs.get_event_factory()
+
+        self.u_alice = hs.parse_userid("@alice:test")
+        self.u_bob = hs.parse_userid("@bob:test")
+
+        self.room1 = hs.parse_roomid("!abc123:test")
+
+        self.depth = 1
+
+    @defer.inlineCallbacks
+    def inject_room_member(self, room, user, membership, prev_state=None,
+                           extra_content={}):
+        self.depth += 1
+
+        event = self.event_factory.create_event(
+            etype=RoomMemberEvent.TYPE,
+            user_id=user.to_string(),
+            state_key=user.to_string(),
+            room_id=room.to_string(),
+            membership=membership,
+            content={"membership": membership},
+            depth=self.depth,
+        )
+
+        event.content.update(extra_content)
+
+        if prev_state:
+            event.prev_state = prev_state
+
+        # Have to create a join event using the eventfactory
+        yield self.store.persist_event(
+            event
+        )
+
+        defer.returnValue(event)
+
+    @defer.inlineCallbacks
+    def inject_message(self, room, user, body):
+        self.depth += 1
+
+        event = self.event_factory.create_event(
+            etype=MessageEvent.TYPE,
+            user_id=user.to_string(),
+            room_id=room.to_string(),
+            content={"body": body, "msgtype": u"message"},
+            depth=self.depth,
+        )
+
+        yield self.store.persist_event(
+            event
+        )
+
+        defer.returnValue(event)
+
+    @defer.inlineCallbacks
+    def inject_redaction(self, room, event_id, user, reason):
+        event = self.event_factory.create_event(
+            etype=RoomRedactionEvent.TYPE,
+            user_id=user.to_string(),
+            room_id=room.to_string(),
+            content={"reason": reason},
+            depth=self.depth,
+            redacts=event_id,
+        )
+
+        yield self.store.persist_event(
+            event
+        )
+
+        defer.returnValue(event)
+
+    @defer.inlineCallbacks
+    def test_redact(self):
+        yield self.inject_room_member(
+            self.room1, self.u_alice, Membership.JOIN
+        )
+
+        start = yield self.store.get_room_events_max_id()
+
+        msg_event = yield self.inject_message(self.room1, self.u_alice, u"t")
+
+        end = yield self.store.get_room_events_max_id()
+
+        results, _ = yield self.store.get_room_events_stream(
+            self.u_alice.to_string(),
+            start,
+            end,
+            None,  # Is currently ignored
+        )
+
+        self.assertEqual(1, len(results))
+
+        # Check event has not been redacted:
+        event = results[0]
+
+        self.assertObjectHasAttributes(
+            {
+                "type": MessageEvent.TYPE,
+                "user_id": self.u_alice.to_string(),
+                "content": {"body": "t", "msgtype": "message"},
+            },
+            event,
+        )
+
+        self.assertFalse(hasattr(event, "redacted_because"))
+
+        # Redact event
+        reason = "Because I said so"
+        yield self.inject_redaction(
+            self.room1, msg_event.event_id, self.u_alice, reason
+        )
+
+        results, _ = yield self.store.get_room_events_stream(
+            self.u_alice.to_string(),
+            start,
+            end,
+            None,  # Is currently ignored
+        )
+
+        self.assertEqual(1, len(results))
+
+        # Check redaction
+
+        event = results[0]
+
+        self.assertObjectHasAttributes(
+            {
+                "type": MessageEvent.TYPE,
+                "user_id": self.u_alice.to_string(),
+                "content": {},
+            },
+            event,
+        )
+
+        self.assertTrue(hasattr(event, "redacted_because"))
+
+        self.assertObjectHasAttributes(
+            {
+                "type": RoomRedactionEvent.TYPE,
+                "user_id": self.u_alice.to_string(),
+                "content": {"reason": reason},
+            },
+            event.redacted_because,
+        )
+
+    @defer.inlineCallbacks
+    def test_redact_join(self):
+        yield self.inject_room_member(
+            self.room1, self.u_alice, Membership.JOIN
+        )
+
+        start = yield self.store.get_room_events_max_id()
+
+        msg_event = yield self.inject_room_member(
+            self.room1, self.u_bob, Membership.JOIN,
+            extra_content={"blue": "red"},
+        )
+
+        end = yield self.store.get_room_events_max_id()
+
+        results, _ = yield self.store.get_room_events_stream(
+            self.u_alice.to_string(),
+            start,
+            end,
+            None,  # Is currently ignored
+        )
+
+        self.assertEqual(1, len(results))
+
+        # Check event has not been redacted:
+        event = results[0]
+
+        self.assertObjectHasAttributes(
+            {
+                "type": RoomMemberEvent.TYPE,
+                "user_id": self.u_bob.to_string(),
+                "content": {"membership": Membership.JOIN, "blue": "red"},
+            },
+            event,
+        )
+
+        self.assertFalse(hasattr(event, "redacted_because"))
+
+        # Redact event
+        reason = "Because I said so"
+        yield self.inject_redaction(
+            self.room1, msg_event.event_id, self.u_alice, reason
+        )
+
+        results, _ = yield self.store.get_room_events_stream(
+            self.u_alice.to_string(),
+            start,
+            end,
+            None,  # Is currently ignored
+        )
+
+        self.assertEqual(1, len(results))
+
+        # Check redaction
+
+        event = results[0]
+
+        self.assertObjectHasAttributes(
+            {
+                "type": RoomMemberEvent.TYPE,
+                "user_id": self.u_bob.to_string(),
+                "content": {"membership": Membership.JOIN},
+            },
+            event,
+        )
+
+        self.assertTrue(hasattr(event, "redacted_because"))
+
+        self.assertObjectHasAttributes(
+            {
+                "type": RoomRedactionEvent.TYPE,
+                "user_id": self.u_alice.to_string(),
+                "content": {"reason": reason},
+            },
+            event.redacted_because,
+        )
diff --git a/tests/utils.py b/tests/utils.py
index bc5d35e56b..bb8e9964dd 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -262,7 +262,7 @@ class MemoryDataStore(object):
         return defer.succeed("invite")
 
     def get_ops_levels(self, room_id):
-        return defer.succeed((5, 5))
+        return defer.succeed((5, 5, 5))
 
 
 def _format_call(args, kwargs):