LOC-24 Added tests for kick/ban functionality
LOC-24 Added tests for kick/ban functionality

Test 11 invites a user, has them join and then kicks them (before verifying in the database)
Test 12 has that user rejoin and then bans them (again verifying against the database)

file:a/.gitignore -> file:b/.gitignore
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
 *~
 *.pyc
+localchat-testing.db
 
 

--- a/client/LocalChatClient.py
+++ b/client/LocalChatClient.py
@@ -16,6 +16,8 @@
 import json
 import urllib2
 import ssl
+import string
+import random
 
 import datetime as dt
 
@@ -37,6 +39,8 @@
         self.server = SERVER
         self.room = False
         self.roompass = False
+        self.sesskey = False
+        self.syskey = False
         self.gpg = gnupg.GPG()
     
     
@@ -49,7 +53,8 @@
         
         payload = {"roomName": self.room, 
                    "mylast":self.last,
-                   "user": self.user
+                   "user": self.user,
+                   "sesskey": self.sesskey
                    }
         
         request = {"action":"pollMsg",
@@ -64,41 +69,70 @@
         
         if resp['status'] == "unchanged":
             return False
+        
+        
+        if resp['status'] == "errmessage":
+            # Got an error, we need to stop the poll and then return the text
+            self.room = False
+            self.roompass = False
+            self.sesskey = False
+            return [['reversed',resp['text']]]
         
         to_print = []
         # Otherwise, process the messages
         for i in resp["messages"]:
             self.last = i[0]
-            msgbody = json.loads(self.decrypt(i[1]))
+            upstruser = i[3]
+            
+            try:
+                msgbody = json.loads(self.decrypt(i[1],upstruser))
+            except:
+                # Means an invalid message was received - LOC-8
+                to_print.append(['error','Received message which could not be decrypted'])
+                continue
             
             # TODO - We'll need to json decode and extract the sending user's name
             # but not currently including that info in my curl tests. Also means test that part of the next block
             
             color = "green"
-            upstruser = msgbody['user'] # Temporary placeholder
-            
+
             if upstruser == self.user:
                 # One of our own, change the color
                 color = "blue"
             
             elif upstruser == "SYSTEM":
                 color = "magenta"
+                if msgbody['verb'] == "sysalert":
+                    color = 'reversed'
+                elif msgbody['verb'] == 'syswarn':
+                    color = 'cyan'
                 
             
             ts = dt.datetime.utcfromtimestamp(i[2]).strftime("[%H:%M:%S]")
             
-            line = [
-                ts, # timestamp
-                "%s>" % (upstruser,), # To be replaced later
-                msgbody['text']
-                ]
+            if msgbody["verb"] == "do":
+                color = 'yellow'
+                line = [
+                    "        ** %s %s **" % (upstruser,msgbody['text'])
+                    ]
+            else:
+                
+                if i[4] != "0":
+                    color = 'brown'
+                    upstruser = 'DM %s' % (upstruser,)
+                
+                line = [
+                    ts, # timestamp
+                    "%s>" % (upstruser,), # To be replaced later
+                    msgbody['text']
+                    ]
             
             to_print.append([color,' '.join(line)])
         
         return to_print
         
         
-    def sendMsg(self,line):
+    def sendMsg(self,line,verb='say'):
         ''' Send a message 
         '''
         
@@ -111,12 +145,14 @@
         
         msg = {
             'user': self.user,
-            'text': line
+            'text': line,
+            "verb": verb
             }
         
         payload = {"roomName": self.room, 
                    "msg":self.encrypt(json.dumps(msg)),
-                   "user": self.user
+                   "user": self.user,
+                   "sesskey": self.sesskey
                    }
         
         request = {"action":"sendMsg",
@@ -125,23 +161,60 @@
 
         resp = self.sendRequest(request)        
         
-        if resp['status'] == "ok":
+        if "status" in resp and resp['status'] == "ok":
             return True
         
         return False
         
+
+
+    def sendDirectMsg(self,line,user,verb='say'):
+        ''' Send a direct message
+        '''
+        
+        if not self.room:
+            # We're not in a room. Abort
+            return False
+        
+        # Otherwise, build a payload
+        
+        msg = {
+            'user': self.user,
+            'text': line,
+            "verb": verb
+            }
+        
+        payload = {"roomName": self.room, 
+                   "msg":self.encrypt(json.dumps(msg)), # TODO this should use the user's key
+                   "to": user,
+                   "user": self.user,
+                   "sesskey": self.sesskey
+                   }
+        
+        request = {"action":"sendDirectMsg",
+                   "payload": json.dumps(payload)
+                   }
+
+        resp = self.sendRequest(request)        
+        
+        if "status" in resp and resp['status'] == "ok":
+            return True
+        
+        return False
+
+
 
 
     def joinRoom(self,user,room,passw):
         ''' Join a room
         '''
         
-        # TODO - this functionality isn't on the 
-        # backend yet, so haven't defined the hashing mechanism etc
-        passhash = self.hashpw(passw)
+        # We only want to send the user password section of the password
+        p = passw.split(":")
+        userpass = p[1]
         
         payload = {"roomName": room, 
-                   "passhash": passhash,
+                   "userpass": userpass.encode('utf-8'),
                    "user": user
                    }
         
@@ -159,7 +232,9 @@
         self.room = room
         self.user = user
         self.last = resp['last']
-        self.roompass = passw
+        self.roompass = p[0] # The room password is the first section of the password
+        self.sesskey = resp['session']
+        self.syskey = resp['syskey']
         return True
 
 
@@ -172,7 +247,8 @@
             return False
         
         payload = {"roomName": self.room, 
-                   "user": self.user
+                   "user": self.user,
+                   "sesskey": self.sesskey
                    }
         
         request = {"action":"leaveRoom",
@@ -189,12 +265,13 @@
         self.user = False
         self.last = 0
         self.roompass = False
+        self.sesskey = False
         
         return True
                 
 
 
-    def createRoom(self,room,passw,user=False):
+    def createRoom(self,room,user=False):
         ''' Create a new room
         '''
         
@@ -203,14 +280,14 @@
         
         if not user:
             user = self.user
-        
-        # Room passwords may well go way at some point, but honour the
-        # api structure for now
-        passhash = self.hashpw(passw)
-        
+
+
+        # Generate a password for the admin
+        passw = self.genpassw()
+                
         payload = {"roomName": room, 
                    "owner": user,
-                   "passhash": passhash
+                   "pass": passw
                    }
         
         request = {"action":"createRoom",
@@ -222,9 +299,29 @@
         if resp == "BROKENLINK" or resp['status'] != "ok":
             return False
         
-        return resp['name']
-    
-
+        return [resp['name'],passw]
+    
+
+    def closeRoom(self):
+        if not self.room:
+            return False
+
+        payload = {"roomName": self.room, 
+                   "user": self.user,
+                   "sesskey": self.sesskey
+                   }
+        
+        request = {"action":"closeRoom",
+                   "payload": json.dumps(payload)
+                   }        
+
+        resp = self.sendRequest(request)
+        if resp == "BROKENLINK" or resp['status'] != "ok":
+            return False
+        
+        return True
+        
+        
 
     def inviteUser(self,user):
         ''' Invite a user into a room
@@ -232,9 +329,15 @@
         #TODO - Authentication
         '''
         
+        
+        # Generate a password for the new user
+        passw = self.genpassw()
+        
         payload = {"roomName": self.room, 
                    "user": self.user,
-                   "invite": user
+                   "invite": user,
+                   "pass": passw,
+                   "sesskey": self.sesskey
                    }
         
         request = {"action":"inviteUser",
@@ -246,7 +349,7 @@
         if resp == "BROKENLINK" or resp['status'] != "ok":
             return False
         
-        return True
+        return [self.room,self.roompass,passw,user]
 
 
 
@@ -261,7 +364,8 @@
         
         payload = {"roomName": self.room, 
                    "user": self.user,
-                   "kick": user
+                   "kick": user,
+                   "sesskey": self.sesskey
                    }
         
         request = {"action":action,
@@ -299,17 +403,19 @@
 
 
 
-    def decrypt(self,msg):
+    def decrypt(self,msg,sender):
         ''' Placeholder
         '''
-        
-        try:
-            # Check if it's json as it may be a system message
-            json.loads(msg)
-            return msg
+                
+        try:       
+            key = self.roompass
+            if sender == "SYSTEM":
+                key = self.syskey
+                
+            return str(self.gpg.decrypt(msg.decode("base64"),passphrase=key))
         
         except:
-            return str(self.gpg.decrypt(msg.decode("base64"),passphrase=self.roompass))
+            return False
         
     
 
@@ -325,6 +431,13 @@
         ''' Placeholder
         '''
         return passw
+
+
+    def genpassw(self,N=16):
+        ''' Generate a random string of chars to act as a password
+        '''
+        
+        return ''.join(random.SystemRandom().choice(string.ascii_uppercase + string.digits).encode('utf-8') for _ in range(N))
 
 
 
@@ -363,6 +476,14 @@
             cmd = cmd[1:]
             
             
+            if cmd == "me":
+                #/me [string]
+                r = msg.sendMsg(' '.join(args),'do')
+                if not r:
+                    raise NotInRoom(line)
+                return
+
+            
             if cmd == "ban":
                 # /kick [user]
                 
@@ -406,21 +527,59 @@
                 global c
                 c.output('Left the room','magenta')
                 return
+
+
+            if cmd == 'msg':
+                # /msg ben Hello ben this is a DM
+                line = ' '.join(args[1:])
+                r = msg.sendDirectMsg(line,args[0])
+                if not r:
+                    raise NotInRoom(line)
+                
+                # Otherwise push a copy of the message to display
+                # cos we won't get this one back from pollMsg
+                global c
+                
+                m = "%s DM %s>%s" % (msg.user,args[0],line)
+                
+                c.output(m,'blue')
+                
+                return
+
         
             if cmd == "room":
-                # /room create [roomname] [roompass] [[user]]
+                
+                # /room close [roompass]
+                if args[0] == "close":
+                    if len(args) < 2:
+                        raise InvalidCommand('/room close [pass]')
+                    
+                    if not msg.closeRoom():
+                        raise UnableTo('Close Room','')
+                                        
+                    return
+                
+                
+                # /room create [roomname] [[user]]
                 if args[0] == "create":
                     
-                    if len(args) < 4:
-                        args[3] = False
+                    if len(args) < 3:
+                        args[2] = False
                     
-                    n = msg.createRoom(args[1],args[2],args[3])
+                    n = msg.createRoom(args[1],args[2])
                     if not n:
                         raise UnableTo('create room',line)
                         return
                     
+                    # Seperate out the return value
+                    rm = n[0]
+                    up = n[1] # user specific password
                     global c
-                    c.output('Created Room %s' %(n))
+                    
+                    # Generate a room password
+                    p = msg.genpassw()
+                    c.output('Created Room %s' %(rm))
+                    c.output('To join the room, do /join %s %s:%s %s' %(args[1],p,up,args[2]))
                     return
                 
                 elif args[0] == "invite":
@@ -434,6 +593,7 @@
                     
                     global c
                     c.output('User %s may now join room' %(args[1],))
+                    c.output('To join the room, they should do /join %s %s:%s %s' %(n[0],n[1],n[2],n[3]))
                     return                                        
                     
 
@@ -463,12 +623,13 @@
             
             
             /room invite [user]                                         Invite a user into the current room
-            
+            /me [string]                                                Send an 'action' instead of a message
             
             Room Admin commands:
             
             /kick [user]                                                Kick a user out of the room (they can return)
             /ban [user]                                                 Kick a user out and disinvite them (they cannot return)
+            /room close [roompass]                                      Kick all users out and close the room
             
             Once in a room, to send a message, just type it.
             
@@ -560,7 +721,12 @@
               ('error', urwid.LIGHT_RED, urwid.BLACK),
               ('green', urwid.DARK_GREEN, urwid.BLACK),
               ('blue', urwid.LIGHT_BLUE, urwid.BLACK),
-              ('magenta', urwid.DARK_MAGENTA, urwid.BLACK), ]
+              ('magenta', urwid.DARK_MAGENTA, urwid.BLACK), 
+              ('yellow', urwid.YELLOW, urwid.BLACK), 
+              ('cyan', urwid.LIGHT_CYAN, urwid.BLACK), 
+              ('brown', urwid.BROWN, urwid.BLACK), 
+              
+              ]
     
     
     def __init__(self, title, command_caption='Message:  (Tab to switch focus to upper frame, where you can scroll text)', cmd_cb=None, max_size=1000):

--- a/server/LocalChat.py
+++ b/server/LocalChat.py
@@ -7,17 +7,25 @@
 # apt-get install:
 #   python-flask
 #   python-openssl
+#   python-bcrypt
 #
 
 from flask import Flask
 from flask import request, make_response
 
-
-
+import thread
+import urllib2
+import ssl
 import sqlite3
 import time
 import os
 import json
+import bcrypt
+import random
+import string
+import gnupg
+import sys
+
 
 app = Flask(__name__)
 
@@ -37,9 +45,9 @@
         return make_response("",400)
     
     a = msghandler.processSubmission(reqjson)
-    
+
     # Check the status
-    if a in [400,403]:
+    if a in [400,403,500]:
         response = make_response("",a)
         return response
         
@@ -49,44 +57,86 @@
 class MsgHandler(object):
 
 
-    def __init__(self):
+    def __init__(self,cronpass,bindpoint,purgeinterval,closethresh,testingmode):
         self.conn = False
         self.cursor = False
+        # Generate a key for encryption of SYSTEM messages (LOC-13)
+        self.syskey = self.genpassw(16)
+        self.gpg = gnupg.GPG()
+        self.cronpass = cronpass
+        self.bindpoint = bindpoint
+        self.purgeInterval = purgeinterval
+        self.roomCloseThresh = closethresh
+        self.testingMode = testingmode
+        
+        if self.testingMode:
+            print "WARNING - Messages will be written to disk"
 
 
 
     def createDB(self):
         ''' Create the in-memory database ready for use 
         '''
-        self.conn = sqlite3.connect(':memory:')
+        
+        dbpath=':memory:'
+        if self.testingMode:
+            # LOC-15 - allow DB to written to disk in test mode
+            dbpath = "%s/localchat-testing.db" % (os.getcwd(),)
+        
+        self.conn = sqlite3.connect(dbpath)
         self.cursor = self.conn.cursor()
         
-        sql = """ CREATE TABLE rooms (
+        sql = """
+        
+        DROP TABLE IF EXISTS rooms;
+        CREATE TABLE rooms (
             id INTEGER PRIMARY KEY,
             name TEXT NOT NULL UNIQUE,
             owner TEXT NOT NULL,
-            pass TEXT NOT NULL
+            lastactivity INTEGER DEFAULT 0
         );
         
-        
+        DROP TABLE IF EXISTS messages;
         CREATE TABLE messages (
             id INTEGER PRIMARY KEY AUTOINCREMENT,
             ts INTEGER NOT NULL,
             room INTEGER NOT NULL,
+            user NOT NULL,
+            touser TEXT DEFAULT '0',
             msg TEXT NOT NULL
         );
         
-        
+        DROP TABLE IF EXISTS users;
         CREATE TABLE users (
             username TEXT NOT NULL,
             room INTEGER NOT NULL,
             active INTEGER DEFAULT 0,
+            passhash TEXT NOT NULL,
             PRIMARY KEY (username,room)
         );
         
+        DROP TABLE IF EXISTS sessions;
+        CREATE TABLE sessions (
+            username TEXT NOT NULL,
+            sesskey TEXT NOT NULL,
+            PRIMARY KEY(sesskey)
+        );
+        
+        DROP TABLE IF EXISTS failuremsgs;
+        CREATE TABLE failuremsgs (
+            username TEXT NOT NULL,
+            room INTEGER NOT NULL,
+            expires INTEGER NOT NULL,
+            msg TEXT NOT NULL,
+            PRIMARY KEY (username,room)
+        );
+        
         """
         
         self.conn.executescript(sql)
+        
+        # We also need to start the scheduler thread (LOC-6)
+        thread.start_new_thread(taskScheduler,(self.cronpass,self.bindpoint))
 
 
 
@@ -101,8 +151,12 @@
         
         
         print reqjson
+        
+        if "action" in reqjson and reqjson['action'] == 'schedulerTrigger':
+            return self.triggerClean(reqjson)
+        
         if "action" not in reqjson or "payload" not in reqjson:
-            return 400
+            return self.returnFailure(400)
         
         
         # Decrypt the payload
@@ -111,11 +165,14 @@
         try:
             reqjson['payload'] = json.loads(reqjson['payload'])
         except:
-            return 400
+            return self.returnFailure(400)
         
         if reqjson['action'] == "createRoom":
             return self.createRoom(reqjson)
         
+        if reqjson['action'] == "closeRoom":
+            return self.closeRoom(reqjson)
+        
         elif reqjson['action'] == "joinRoom":
             return self.processjoinRoom(reqjson)
         
@@ -130,6 +187,9 @@
         
         elif reqjson['action'] == "kickUser":
             return self.kickUser(reqjson,False)
+        
+        elif reqjson['action'] == 'sendDirectMsg':
+            return self.sendDirectMsg(reqjson)
         
         elif reqjson['action'] == 'sendMsg':
             return self.sendMsg(reqjson)
@@ -170,22 +230,34 @@
         }'
         
         '''
+        
+        # Validate the request
+        #
+        # All validation snippets will change to this format soon
+        required = ['roomName','owner','pass']
+        for i in required:
+            if i not in reqjson['payload']:
+                return self.returnFailure(400)
+        
         print "Creating room %s" % (reqjson['payload'])
         
         # Create a tuple for sqlite3
         t = (reqjson['payload']['roomName'],
-             reqjson['payload']['owner'],
-             reqjson['payload']['passhash'])
+             reqjson['payload']['owner']
+             )
         
         try:
-            self.cursor.execute("INSERT INTO rooms (name,owner,pass) VALUES (?,?,?)",t)
+            self.cursor.execute("INSERT INTO rooms (name,owner) VALUES (?,?)",t)
             roomid = self.cursor.lastrowid
         except:
             # Probably a duplicate name, but we don't want to give the other end a reason anyway
-            return 500
-        
-        
-        self.cursor.execute("INSERT INTO users (username,room) values (?,?)",(reqjson['payload']['owner'],roomid))
+            return self.returnFailure(500)
+        
+        
+        # Generate a password hash for the owners password
+        passhash = bcrypt.hashpw(reqjson['payload']['pass'].encode('utf-8'),bcrypt.gensalt())
+        
+        self.cursor.execute("INSERT INTO users (username,room,passhash) values (?,?,?)",(reqjson['payload']['owner'],roomid,passhash))
         self.conn.commit()
         
         return {
@@ -194,52 +266,81 @@
                 'name' : reqjson['payload']['roomName']
             }
         
-    
+
+
+    def closeRoom(self,reqjson):
+        ''' Close a room.
+        
+        Means we need to
+        
+        - Ban all the users
+        - Scrub the message queue
+        - Remove the room record
+        '''
+        
+        if "roomName" not in reqjson['payload'] or "user" not in reqjson['payload']:
+            return self.returnFailure(400)
+        
+        room = self.getRoomID(reqjson['payload']["roomName"])
+        
+        if not room:
+            return self.returnFailure(400)
+        
+        
+        # Check the requesting user is the admin
+        self.cursor.execute("SELECT * from rooms where id=? and owner=?",(room,reqjson["payload"]["user"]))
+        n = self.cursor.fetchone()
+        
+        if not n:
+            return self.returnFailure(403)
+        
+
+        self.pushSystemMsg("Room has been closed. Buh-Bye",room,'syswarn')
+                
+        self.cursor.execute("DELETE FROM users where room=?",(room,))
+        self.cursor.execute("DELETE FROM rooms where id=?",(room,))
+        self.cursor.execute("DELETE FROM messages where room=?",(room,))
+        self.cursor.execute("DELETE FROM sessions where sesskey like ?", (reqjson['payload']["roomName"] + '-%',))
+        
+        self.conn.commit()
+        
+        return { "status" : "ok" }
+    
+        
+           
+        
 
 
     def inviteUser(self,reqjson):
         ''' Link a username into a room
-
-
-        curl -v -X POST http://127.0.0.1:8090/ -H "Content-Type: application/json" --data '{"action":"inviteUser","payload":"{\"roomName\":\"BenTest\",\"user\":\"ben2\"}"}'
-
-        '''
-        
-        if "roomName" not in reqjson['payload']:
-            return 400
+        '''
+        
+        if "roomName" not in reqjson['payload'] or "pass" not in reqjson['payload'] or "invite" not in reqjson['payload']:
+            return self.returnFailure(400)
         
         room = self.getRoomID(reqjson['payload']["roomName"])
         
         if not room:
-            return 400
+            return self.returnFailure(400)
         
         if not self.validateUser(reqjson['payload']):
-            return 403
+            return self.returnFailure(403,reqjson['payload'],room)
         
         
         if reqjson['payload']['invite'] == "SYSTEM":
             # Push a notification into the group
-            m = {
-                    "user":"SYSTEM",
-                    "text":"ALERT: User %s tried to invite SYSTEM" % (reqjson['payload']['user'])
-                }
-            self.cursor.execute("INSERT INTO messages (ts,room,msg) VALUES (?,?,?)",(time.time(),room,json.dumps(m))) 
-            self.conn.commit()
-            return 403
+            self.pushSystemMsg("ALERT: User %s tried to invite SYSTEM" % (reqjson['payload']['user']),room,'sysalert')
+            return self.returnFailure(403)
        
         
+        # Generate a hash of the submitted password
+        passhash = bcrypt.hashpw(reqjson['payload']['pass'].encode('utf-8'),bcrypt.gensalt())
         
         # Otherwise, link the user in
-        self.cursor.execute("INSERT INTO users (username,room) values (?,?)",(reqjson['payload']['invite'],room))
+        self.cursor.execute("INSERT INTO users (username,room,passhash) values (?,?,?)",(reqjson['payload']['invite'],room,passhash))
         
         # Push a notification into the group
-        m = {
-                "user":"SYSTEM",
-                "text":"User %s invited %s to the room" % (reqjson['payload']['user'],reqjson['payload']['invite'])
-            }
-        
-        self.cursor.execute("INSERT INTO messages (ts,room,msg) VALUES (?,?,?)",(time.time(),room,json.dumps(m)))        
-        self.conn.commit()        
+        self.pushSystemMsg("User %s invited %s to the room" % (reqjson['payload']['user'],reqjson['payload']['invite']),room)
         
         return {
                 "status":'ok'
@@ -254,12 +355,12 @@
         '''
         
         if "roomName" not in reqjson['payload'] or "user" not in reqjson['payload'] or "kick" not in reqjson['payload']:
-            return 400
+            return self.returnFailure(400)
         
         room = self.getRoomID(reqjson['payload']["roomName"])
         
         if not room:
-            return 400
+            return self.returnFailure(400)
         
         
         # Check the requesting user is the admin
@@ -267,155 +368,83 @@
         n = self.cursor.fetchone()
         
         if not n:
-            return 403
+            return self.returnFailure(403)
         
         
         
         self.cursor.execute("UPDATE users set active=0 where room=? and username=?",(room,reqjson["payload"]["kick"]))
-        m = {
-                "user":"SYSTEM",
-                "text":"User %s kicked %s from the room" % (reqjson['payload']['user'],reqjson['payload']['kick'])
-            }
-        
-        self.cursor.execute("INSERT INTO messages (ts,room,msg) VALUES (?,?,?)",(time.time(),room,json.dumps(m)))
-        msgid = self.cursor.lastrowid
-        self.conn.commit()
-                    
+        
+        # Delete their session
+        self.cursor.execute("DELETE FROM sessions where username=? and sesskey like ?", (reqjson['payload']['kick'],reqjson['payload']["roomName"] + '-%'))
+        
+        self.pushSystemMsg("User %s kicked %s from the room" % (reqjson['payload']['user'],reqjson['payload']['kick']),room,'syswarn')
+        
+        self.pushFailureMessage(reqjson['payload']['kick'],room,'You have been kicked from the room')
+        
+        
         if ban:
             # If we're banning them, also need to disinvite them
             self.cursor.execute("DELETE from users where room=? and username=?",(room,reqjson["payload"]["kick"]))
-            m = {
-                    "user":"SYSTEM",
-                    "text":"User %s banned %s from the room" % (reqjson['payload']['user'],reqjson['payload']['kick'])
-                }
+            self.pushSystemMsg("User %s banned %s from the room" % (reqjson['payload']['user'],reqjson['payload']['kick']),room,'syswarn')
             
-            self.cursor.execute("INSERT INTO messages (ts,room,msg) VALUES (?,?,?)",(time.time(),room,json.dumps(m)))
-            self.conn.commit()
-        
         return { "status" : "ok" }
     
-    
-    
+
     
     def processjoinRoom(self,reqjson):
         ''' Process a request from a user to login to a room
         
         Not yet defined the authentication mechanism to use, so that's a TODO
         '''
-        if "roomName" not in reqjson['payload'] or "user" not in reqjson['payload']:
-            return 400
+
+        # Check the required information is present
+        required = ['roomName','user','userpass']
+        for i in required:
+            if i not in reqjson['payload']:
+                return self.returnFailure(400)
+                
         
         room = self.getRoomID(reqjson['payload']["roomName"])
         
         if not room:
-            return 400
+            return self.returnFailure(400)
         
         
         if reqjson["payload"]["user"] == "SYSTEM":
-            return 403
+            return self.returnFailure(403)
         
         # Check whether that user is authorised to connect to that room
-        self.cursor.execute("SELECT username, room from users where username=? and room=?",(reqjson['payload']['user'],room))
+        self.cursor.execute("SELECT username, room,passhash from users where username=? and room=?",(reqjson['payload']['user'],room))
         r = self.cursor.fetchone()
         
         if not r:
             return { "status": "NOK" }
-        else:
+        
+        
+        # Now we need to verify they've supplied a correct password for that user
+        stored = r[2].encode("utf-8")
+        if stored != bcrypt.hashpw(reqjson['payload']['userpass'].encode('utf-8'),stored):
+            return self.returnFailure(403)
+        
             
-            
-            # Tidy older messages away.
-            #
-            # We do this so that a user who joins can't then send a poll with last:0 to retrieve the full history
-            #
-            # Basically, anything older than 10 seconds should go. Users who were already present will be able
-            # to scroll up and down in their client anyway
-            self.tidyMsgs(time.time()-10,room)
-            
-            
-            # Push a message to the room to note that the user joined
-            
-            m = {
-                    "user":"SYSTEM",
-                    "text":"User %s joined the room" % (reqjson['payload']['user'])
-                }
-            
-            self.cursor.execute("INSERT INTO messages (ts,room,msg) VALUES (?,?,?)",(time.time(),room,json.dumps(m)))
-            msgid = self.cursor.lastrowid
-            self.conn.commit()
-            
-            # Check the latest message ID for that room
-            self.cursor.execute("SELECT id from messages WHERE room=? and id != ? ORDER BY id DESC",(room,msgid))
-            r = self.cursor.fetchone()
-            
-            if not r:
-                last = 0
-            else:
-                last = r[0]
-                   
-            # Mark the user as active in the users table
-            self.cursor.execute("UPDATE users set active=1 where username=? and room=?", (reqjson['payload']['user'],room))
-            self.conn.commit()
-            
-            
-            return {"status":"ok","last":last}
-        
-        
-    def processleaveRoom(self,reqjson):
-        ''' Process a user's request to leave a room
-        '''
-        if "roomName" not in reqjson['payload'] or "user" not in reqjson['payload']:
-            return 400
-        
-        room = self.getRoomID(reqjson['payload']["roomName"])
-        
-        if not room:
-            return 400
-        
-        # Check the user is actually in the room and authorised
-        if not self.validateUser(reqjson['payload']):
-            return 400
-        
-        # Mark them as not in the room
-        self.cursor.execute("UPDATE users set active=0 where username=? and room=?", (reqjson['payload']['user'],room))
-        self.conn.commit()
-        
-        # Push a message to the room to note they left
-        m = {
-                "user":"SYSTEM",
-                "text":"User %s left the room" % (reqjson['payload']['user'])
-            }
-        
-        self.cursor.execute("INSERT INTO messages (ts,room,msg) VALUES (?,?,?)",(time.time(),room,json.dumps(m)))
-        msgid = self.cursor.lastrowid
-        self.conn.commit()        
-        
-        return {"status":"ok"}
-    
-    
-    def sendMsg(self,reqjson):
-        ''' Push a message into a room
-        
-        curl -v -X POST http://127.0.0.1:8090/ -H "Content-Type: application/json" --data '{"action":"sendMsg","payload":"{\"roomName\":\"BenTest\", \"msg\":\"ENCRYPTED-DATA\",\"user\":\"ben2\"}"}'
-        
-        '''
-        
-        if not self.validateUser(reqjson['payload']):
-            return 403
-        
-        
-        if "roomName" not in reqjson['payload'] or "msg" not in reqjson['payload']:
-            return 400
-        
-        room = self.getRoomID(reqjson['payload']["roomName"])
-        print room
-        if not room:
-            return 400
-
-            
-        self.cursor.execute("INSERT INTO messages (ts,room,msg) VALUES (?,?,?)",(time.time(),room,reqjson['payload']['msg']))
-        msgid = self.cursor.lastrowid
-        self.conn.commit()
-        
+        # Tidy older messages away.
+        #
+        # We do this so that a user who joins can't then send a poll with last:0 to retrieve the full history
+        #
+        # Basically, anything older than 10 seconds should go. Users who were already present will be able
+        # to scroll up and down in their client anyway
+        self.tidyMsgs(time.time()-10,room)
+        
+        
+        # Push a message to the room to note that the user joined
+        msgid = self.pushSystemMsg("User %s joined the room" % (reqjson['payload']['user']),room)
+
+
+        # If we're in testing mode, push a warning so the new user can see it
+        if self.testingMode:
+            msgid = self.pushSystemMsg("Server is in testing mode. Messages are being written to disk",room,'syswarn')
+
+
         # Check the latest message ID for that room
         self.cursor.execute("SELECT id from messages WHERE room=? and id != ? ORDER BY id DESC",(room,msgid))
         r = self.cursor.fetchone()
@@ -424,6 +453,84 @@
             last = 0
         else:
             last = r[0]
+                
+        # Mark the user as active in the users table
+        self.cursor.execute("UPDATE users set active=1 where username=? and room=?", (reqjson['payload']['user'],room))
+        
+        
+        # Create a session for the user
+        sesskey = "%s-%s" % (reqjson['payload']["roomName"],self.genSessionKey())
+        self.cursor.execute("INSERT INTO sessions (username,sesskey) values (?,?)", (reqjson['payload']['user'],sesskey))
+        self.conn.commit()
+                
+        return {"status":"ok","last":last,"session":sesskey,"syskey":self.syskey}
+        
+        
+    def processleaveRoom(self,reqjson):
+        ''' Process a user's request to leave a room
+        '''
+        if "roomName" not in reqjson['payload'] or "user" not in reqjson['payload']:
+            return self.returnFailure(400)
+        
+        room = self.getRoomID(reqjson['payload']["roomName"])
+        
+        if not room:
+            return self.returnFailure(400)
+        
+        # Check the user is actually in the room and authorised
+        if not self.validateUser(reqjson['payload']):
+            return self.returnFailure(400)
+        
+        # Mark them as not in the room
+        self.cursor.execute("UPDATE users set active=0 where username=? and room=?", (reqjson['payload']['user'],room))
+        self.conn.commit()
+        
+        # Delete their session
+        self.cursor.execute("DELETE FROM sessions where username=? and sesskey = ?", (reqjson['payload']['user'],reqjson['payload']["sesskey"]))
+        
+        # Push a message to the room to note they left
+        self.pushSystemMsg("User %s left the room" % (reqjson['payload']['user']),room)
+        return {"status":"ok"}
+    
+    
+    def sendMsg(self,reqjson):
+        ''' Push a message into a room
+        
+        curl -v -X POST http://127.0.0.1:8090/ -H "Content-Type: application/json" --data '{"action":"sendMsg","payload":"{\"roomName\":\"BenTest\", \"msg\":\"ENCRYPTED-DATA\",\"user\":\"ben2\"}"}'
+        
+        '''
+        
+        if not self.validateUser(reqjson['payload']):
+            return self.returnFailure(403)
+        
+        
+        if "roomName" not in reqjson['payload'] or "msg" not in reqjson['payload']:
+            return self.returnFailure(400)
+        
+        room = self.getRoomID(reqjson['payload']["roomName"])
+        print room
+        if not room:
+            return self.returnFailure(400)
+
+            
+        self.cursor.execute("INSERT INTO messages (ts,room,msg,user) VALUES (?,?,?,?)",(time.time(),room,reqjson['payload']['msg'],reqjson['payload']['user']))
+        msgid = self.cursor.lastrowid
+        self.conn.commit()
+        
+        # Check the latest message ID for that room
+        self.cursor.execute("SELECT id from messages WHERE room=? and id != ? ORDER BY id DESC",(room,msgid))
+        r = self.cursor.fetchone()
+        
+        if not r:
+            last = 0
+        else:
+            last = r[0]
+        
+        
+        # Update the last activity field in the DB
+        # See LOC-11
+        self.cursor.execute("UPDATE rooms set lastactivity=? where id=?",(time.time(),room))
+        self.conn.commit()
         
         return {
                 "status" : "ok",
@@ -431,30 +538,84 @@
                 "last" : last
             }
         
-        
-    def fetchMsgs(self,reqjson):
-        ''' Check to see if there are any new messages in the room
-        
-        curl -v -X POST http://127.0.0.1:8090/ -H "Content-Type: application/json" --data '{"action":"pollMsg","payload":"{\"roomName\":\"BenTest\", \"mylast\":1,\"user\":\"ben2\"}"}'
-        
+
+
+    def sendDirectMsg(self,reqjson):
+        ''' Push a message to a user in the same room
         '''
         
         if not self.validateUser(reqjson['payload']):
-            return 403
-
-        if "mylast" not in reqjson['payload']:
-            return 400
+            return self.returnFailure(403)
+                
+        required = ['roomName','msg','to']
+        for i in required:
+            if i not in reqjson['payload']:
+                return self.returnFailure(400)
         
         room = self.getRoomID(reqjson['payload']["roomName"])
         print room
         if not room:
-            return 400        
-        
-        self.cursor.execute("""SELECT id,msg,ts FROM messages
+            return self.returnFailure(400)
+
+        # Check the other user is in the room and active
+        self.cursor.execute("SELECT username from users where username=? and room=? and active=1",(reqjson['payload']['to'],room))
+        r = self.cursor.fetchone()
+        
+        if not r:
+            return self.returnFailure(400)
+            
+        self.cursor.execute("INSERT INTO messages (ts,room,msg,user,touser) VALUES (?,?,?,?,?)",(time.time(),room,reqjson['payload']['msg'],reqjson['payload']['user'],reqjson['payload']['to']))
+        msgid = self.cursor.lastrowid
+        self.conn.commit()
+        
+        # Update the last activity field in the DB
+        # See LOC-11
+        self.cursor.execute("UPDATE rooms set lastactivity=? where id=?",(time.time(),room))
+        self.conn.commit()        
+        
+        # Check the latest message ID for that room
+        self.cursor.execute("SELECT id from messages WHERE room=? and id != ? ORDER BY id DESC",(room,msgid))
+        r = self.cursor.fetchone()
+        
+        if not r:
+            last = 0
+        else:
+            last = r[0]
+        
+        return {
+                "status" : "ok",
+                "msgid" : msgid,
+                "last" : last
+            }
+        
+
+
+        
+    def fetchMsgs(self,reqjson):
+        ''' Check to see if there are any new messages in the room
+        
+        curl -v -X POST http://127.0.0.1:8090/ -H "Content-Type: application/json" --data '{"action":"pollMsg","payload":"{\"roomName\":\"BenTest\", \"mylast\":1,\"user\":\"ben2\"}"}'
+        
+        '''
+
+        if "mylast" not in reqjson['payload']:
+            return self.returnFailure(400)
+        
+        room = self.getRoomID(reqjson['payload']["roomName"])
+        print room
+        if not room:
+            return self.returnFailure(400)
+
+
+        if not self.validateUser(reqjson['payload']):
+            return self.returnFailure(403,reqjson['payload'],room)
+        
+        self.cursor.execute("""SELECT id,msg,ts,user,touser FROM messages
             WHERE room=? AND
+            (touser = 0 OR touser = ?) AND 
             id > ?
             ORDER BY ts ASC           
-            """,(room,reqjson['payload']['mylast']))
+            """,(room,reqjson['payload']['user'],reqjson['payload']['mylast']))
         
         r = self.cursor.fetchall()
         
@@ -479,9 +640,17 @@
             return False
         
         
+        # Validate the session information
+        self.cursor.execute("SELECT username from sessions where username=? and sesskey=?",(payload['user'],payload['sesskey']))
+        r = self.cursor.fetchone();
+        
+        if not r:
+            return False
+        
+        
         room = self.getRoomID(payload["roomName"])
         if not room:
-            return 400        
+            return False        
         
         
         
@@ -509,10 +678,32 @@
         return r[0]
     
 
+
+    def triggerClean(self,reqjson):
+        ''' Trigger a clean of old messages etc
+        '''
+        
+        if 'pass' not in reqjson:
+            # No need for failure messages here
+            return 403
+        
+        if reqjson['pass'] != self.cronpass:
+            return 403
+        
+        # Tidy messages older than 10 minutes
+        self.tidyMsgs(time.time() - self.purgeInterval);
+        
+        # Auto-close any rooms beyond the threshold
+        self.autoCloseRooms()
+        
+        return {'status':'ok'}
+        
+        
+
     def tidyMsgs(self,thresholdtime,room=False):
         ''' Remove messages older than the threshold time
         '''
-        
+
         if room:
             # Tidy from a specific room
             self.cursor.execute("DELETE FROM messages where ts < ? and room = ?",(thresholdtime,room))
@@ -522,20 +713,177 @@
             self.cursor.execute("DELETE FROM messages where ts < ?",(thresholdtime,))
             self.conn.commit()
 
-
-
-    def test(self):
-        return ['foo']
-
-
-
-
-# Create a global instance of the wrapper so that state can be retained
-msghandler = MsgHandler()
+        # Tidy away any failure messages
+        self.cursor.execute("DELETE FROM failuremsgs  where expires < ?",(time.time(),))
+
+
+
+
+    def autoCloseRooms(self):
+        ''' Automatically close any rooms that have been sat idle for too long
+        '''
+        
+        self.cursor.execute("SELECT id,name from rooms where lastactivity < ? and lastactivity > 0",(time.time() - self.roomCloseThresh,))
+        rooms = self.cursor.fetchall()
+        
+        # Messages probably have been auto-purged, but make sure
+        for r in rooms:
+            self.cursor.execute("DELETE FROM messages where room=?",(r[0],))
+            self.cursor.execute("DELETE FROM failuremsgs where room=?",(r[0],))
+            self.cursor.execute("DELETE FROM users where room=?",(r[0],))
+            self.cursor.execute("DELETE FROM sessions where sesskey like ?", (r[1] + '-%',))
+            self.cursor.execute("DELETE FROM rooms where id=?",(r[0],))
+            self.conn.commit()
+            
+            
+        
+
+
+
+    def pushSystemMsg(self,msg,room,verb="sysinfo"):
+        ''' Push a message from the SYSTEM user into the queue
+        '''
+        m = {
+            "text":msg,
+            "verb":verb
+        }
+        
+        m = self.encryptSysMsg(json.dumps(m))
+        
+        self.cursor.execute("INSERT INTO messages (ts,room,msg,user) VALUES (?,?,?,'SYSTEM')",(time.time(),room,m))
+        msgid = self.cursor.lastrowid
+        
+        # Update the last activity field in the DB
+        # See LOC-11
+        self.cursor.execute("UPDATE rooms set lastactivity=? where id=?",(time.time(),room))
+        
+        self.conn.commit()
+        return msgid
+
+
+    def pushFailureMessage(self,user,room,msg):
+        ''' Record a failure message against a user
+        
+        See LOC-14
+        
+        '''
+        self.cursor.execute("INSERT INTO failuremsgs (username,room,expires,msg) values (?,?,?,?)",(user,room,time.time() + 300,msg))
+        self.conn.commit()
+        
+        
+
+
+    def returnFailure(self,status,reqjson=False,room=False):
+        ''' For whatever reason, a request isn't being actioned. We need to return a status code
+        
+        However, in some instances, we may allow a HTTP 200 just once in order to send the user
+        information on why their next request will fail 
+        '''
+        
+        # TODO - implement the failure handling stuff
+        
+        if reqjson and room:
+            # Check whether there's a failure message or not 
+            self.cursor.execute("SELECT msg from failuremsgs where username=? and room=?",(reqjson['user'],room))
+            r = self.cursor.fetchone()
+            
+            if not r:
+                # No message to return
+                return status
+            
+            # Otherwise, return the message and remove it
+            self.cursor.execute("DELETE from failuremsgs where username=? and room=?",(reqjson['user'],room))
+            self.conn.commit()
+            return {"status":"errmessage","text":r[0]}
+        
+        
+        return status
+        
+
+
+    def encryptSysMsg(self,msg):
+        ''' Encrypt a message from system - LOC-13
+        
+            This isn't so much for protection of the data in memory (as the key is also in memory) as it
+            is to protect against a couple of things you could otherwise do in the client. See LOC-13 for
+            more info.
+        
+        '''
+        
+        crypted = self.gpg.encrypt(msg,None,passphrase=self.syskey,symmetric="AES256",armor=False)
+        return crypted.data.encode('base64')
+        
+
+
+    def genSessionKey(self,N=48):
+        return ''.join(random.SystemRandom().choice(string.ascii_letters + string.digits + '/=?&@#%^()+,.<>:!').encode('utf-8') for _ in range(N))
+
+
+
+    def genpassw(self,N=16):
+        ''' Generate a random string of chars to act as an encryption password
+        '''
+        
+        return ''.join(random.SystemRandom().choice(string.ascii_letters + string.digits).encode('utf-8') for _ in range(N))
+
+
+
+# Create the scheduler function
+def taskScheduler(passw,bindpoint):
+    
+    
+    # Ignore cert errors
+    ctx = ssl.create_default_context()
+    ctx.check_hostname = False
+    ctx.verify_mode = ssl.CERT_NONE
+    
+    data = json.dumps({"action":'schedulerTrigger',
+            "pass": passw
+            })
+    
+    while True:
+        time.sleep(60)
+        
+        try:
+            req = urllib2.Request(bindpoint, data, {'Content-Type': 'application/json'})
+            f = urllib2.urlopen(req,context=ctx)
+            response = f.read()
+            f.close()  
+        except:
+            # Don't let the thread abort just because one request went wrong
+            continue
+
+    
 
 
 if __name__ == '__main__':
+    
+    # LOC-15
+    testingmode = False
+    if '--testing-mode-enable' in sys.argv:
+        testingmode = True
+    
+    
+    # These will be handled properly later
+    passw = ''.join(random.SystemRandom().choice(string.ascii_letters + string.digits).encode('utf-8') for _ in range(64))
+    bindpoint = "https://127.0.0.1:8090" 
+    purgeinterval = 600 # Wipe messages older than 10 mins
+    closethresh = 3600 * 6 # Auto-close rooms after 6 hours of inactivity
+
+    # Create a global instance of the wrapper so that state can be retained
+    #
+    # We pass in the password we generated for the scheduler thread to use
+    # as well as the URL it should POST to
+    msghandler = MsgHandler(passw,bindpoint,purgeinterval,closethresh,testingmode)
+
+
     # Bind to PORT if defined, otherwise default to 8090.
     port = int(os.environ.get('PORT', 8090))
     app.run(host='0.0.0.0', port=port,debug=True,ssl_context='adhoc')
 
+
+
+
+
+
+

--- /dev/null
+++ b/tests/run_tests.py
@@ -1,1 +1,812 @@
-
+#!/usr/bin/env python
+#
+# apt-get install:
+#   python-psutil
+#
+import subprocess
+import psutil
+import os
+import sys
+import time
+import sqlite3
+import traceback
+import json
+
+
+try:
+    from subprocess import DEVNULL # py3k
+except ImportError:
+    import os
+    DEVNULL = open(os.devnull, 'wb')
+
+
+DB_FILE = "%s/localchat-testing.db" % (os.getcwd(),)
+PARENT_DIR = "%s/.." % (os.path.dirname(os.path.abspath(__file__)),)
+sys.path.append('%s/client/' % (PARENT_DIR,))
+
+import LocalChatClient
+STORAGE = {}
+
+
+def restartServer(proc1):
+    ''' Start the server component
+    
+    If already running (i.e. proc1 != False) kill the existing instance
+    '''
+    
+    # If we've already got a process, kill it
+    if proc1:
+        kill(proc1.pid)
+    
+    try:
+        serverloc = "%s/server/LocalChat.py" % (PARENT_DIR,)
+        proc1 = subprocess.Popen([serverloc,'--testing-mode-enable'],stderr=subprocess.STDOUT,stdout=DEVNULL)
+    except Exception as e:
+        print "Failed to start server"
+        print e
+        return False
+    
+    # Give it time to start up
+    time.sleep(5)
+    return proc1
+
+
+def getClientInstance():
+    ''' Get an instance of the client class
+    '''
+    return LocalChatClient.msgHandler()
+
+
+def exit(proc1,code=0):
+    ''' Tidy up and exit
+    '''
+    
+    if proc1:
+        kill(proc1.pid)
+        
+    sys.exit(code)
+
+
+
+def kill(proc_pid):
+    ''' Kill the process and it's children
+    
+    From https://stackoverflow.com/a/25134985
+    
+    We use this because proc1.kill() doesn't seem to kill of the child thread of the server, even if shell=True
+    '''
+    process = psutil.Process(proc_pid)
+    for proc in process.children(recursive=True):
+        proc.kill()
+    process.kill()
+
+
+
+def make_table(columns, data):
+    """Create an ASCII table and return it as a string.
+
+    Pass a list of strings to use as columns in the table and a list of
+    dicts. The strings in 'columns' will be used as the keys to the dicts in
+    'data.'
+
+
+    https://snippets.bentasker.co.uk/page-1705192300-Make-ASCII-Table-Python.html
+    """
+    # Calculate how wide each cell needs to be
+    cell_widths = {}
+    for c in columns:
+        lens = []
+        values = [lens.append(len(str(d.get(c, "")))) for d in data]
+        lens.append(len(c))
+        lens.sort()
+        cell_widths[c] = max(lens)
+
+    # Used for formatting rows of data
+    row_template = "|" + " {} |" * len(columns)
+
+    # CONSTRUCT THE TABLE
+
+    # The top row with the column titles
+    justified_column_heads = [c.ljust(cell_widths[c]) for c in columns]
+    header = row_template.format(*justified_column_heads)
+    # The second row contains separators
+    sep = "|" + "-" * (len(header) - 2) + "|"
+    end = "-" * len(header)
+    # Rows of data
+    rows = []
+
+    for d in data:
+        fields = [str(d.get(c, "")).ljust(cell_widths[c]) for c in columns]
+        row = row_template.format(*fields)
+        rows.append(row)
+    rows.append(end)
+    return "\n".join([header, sep] + rows)
+
+
+
+def opendb():
+    ''' We'll do this and then close after every query to make sure we don't
+    inadvertantly lock the DB and force tests to fail.
+    
+    '''
+    CONN = sqlite3.connect(DB_FILE)
+    CURSOR = CONN.cursor()
+
+    return [CONN,CURSOR]
+    
+
+def run_tests():
+    
+    # Get an instance of the client
+    msg = getClientInstance();
+    
+    test_results = []
+    tests = ['test_one','test_two','test_three','test_four',
+             'test_five','test_six','test_seven','test_eight',
+             'test_nine','test_ten','test_eleven','test_twelve']
+    x = 1
+    for test in tests:
+        print "Running %s " % (test,)
+        stat,isFatal = globals()[test](msg)
+        stat['No'] = x
+        test_results.append(stat)
+        if isFatal and stat['Result'] == 'FAIL':
+            break
+
+        x = x + 1
+
+    return test_results
+
+
+
+
+def test_one(msg):
+    ''' Create a room and verify that it gets created
+    '''
+    
+    result = {'Test' : 'Create a Room','Result' : 'FAIL', 'Notes': '' }
+    isFatal = True
+    # Test 1 - create a room and check it's recorded in the DB
+    n = msg.createRoom('TestRoom1','testadmin')
+    
+    if not n:
+        result['Notes'] = 'Empty Response'
+        return [result,isFatal]
+    
+    # The client should have given us two passwords
+    if len(n) < 2:
+        result['Notes'] = 'Response too small'
+        return [result,isFatal]
+    
+    # Seperate out the return value
+    roompass = n[0]
+    userpass = n[1] # user specific password    
+
+    STORAGE['room'] = {"name":"TestRoom1",
+                       "RoomPass":roompass,
+                       "UserPass":userpass,
+                       'User':'testadmin'
+                       }
+    
+    
+    CONN,CURSOR = opendb()
+    
+    # Check the DB to ensure the room was actually created
+    CURSOR.execute("SELECT * from rooms where name=?",('TestRoom1',))
+    r = CURSOR.fetchone()
+    CONN.close()
+    
+    if not r:
+        result['Notes'] = 'Room not in DB'
+        return [result,isFatal]
+    
+    result['Result'] = 'Pass'
+    return [result,isFatal]
+
+
+
+
+def test_two(msg):
+    ''' Try joining the previously created room with invalid credentials
+    '''
+    
+    result = {'Test' : 'Join the room with invalid creds','Result' : 'FAIL', 'Notes': '' }
+    isFatal = False
+    n = msg.joinRoom(STORAGE['room']['User'],STORAGE['room']['name'],
+                     "%s:%s" % (STORAGE['room']['RoomPass'],'BlatantlyWrong'))
+    
+    if n:
+        result['Notes'] = 'Allowed to join with invalid pass'
+        return [result,isFatal]
+    
+    
+    # Now try with an invalid username
+    result = {'Test' : 'Join the room with invalid creds','Result' : 'FAIL', 'Notes': '' }
+    n = msg.joinRoom('AlsoWrong',STORAGE['room']['name'],"%s:%s" % (STORAGE['room']['RoomPass'],'BlatantlyWrong'))
+    
+    if n:
+        result['Notes'] = 'Invalid user Allowed to join'
+        return [result,isFatal]
+        
+    result['Result'] = 'Pass'
+    return [result,isFatal]
+
+
+
+
+def test_three(msg):
+    ''' Join the previously created room
+    '''
+    
+    result = {'Test' : 'Join the room','Result' : 'FAIL', 'Notes': '' }
+    isFatal = True
+    n = msg.joinRoom(STORAGE['room']['User'],STORAGE['room']['name'],
+                     "%s:%s" % (STORAGE['room']['RoomPass'],STORAGE['room']['UserPass'])
+                     )
+    
+    if not n:
+        result['Notes'] = 'Could not join'
+        return [result,isFatal]
+    
+    CONN,CURSOR = opendb()
+    
+    # Check the DB to ensure we're now active
+    CURSOR.execute("SELECT * from users where username=? and active=1",(STORAGE['room']['User'],))
+    r = CURSOR.fetchone()
+    CONN.close()
+    
+    if not r:
+        result['Notes'] = 'Not Active in DB'
+        return [result,isFatal]
+    
+    # Check we've got a session token
+    if not msg.sesskey:
+        result['Notes'] = 'No Session Key'
+        return [result,isFatal]
+        
+    # Check the client has recorded what it needs to
+    if not msg.room:
+        result['Notes'] = 'Client forgot room'
+        return [result,isFatal]
+
+    if not msg.user:
+        result['Notes'] = 'Client forgot user'
+        return [result,isFatal]
+
+    if not msg.roompass:
+        result['Notes'] = 'Client forgot roompass'
+        return [result,isFatal]
+
+    if not msg.syskey:
+        result['Notes'] = 'No SYSTEM key'
+        return [result,isFatal]
+    
+    
+    result['Result'] = 'Pass'
+    return [result,isFatal]
+
+
+def test_four(msg):
+    ''' When we joined, SYSTEM will have pushed a message. Ensure it's encrypted
+    '''
+    
+    result = {'Test' : 'SYSTEM uses E2E','Result' : 'FAIL', 'Notes': '' }
+    isFatal = False
+
+
+    CONN,CURSOR = opendb()
+
+    CURSOR.execute("SELECT msg FROM messages where user='SYSTEM' ORDER BY ts DESC")
+    r = CURSOR.fetchone()
+    CONN.close()
+    
+    if not r:
+        result['Notes'] = 'No System Message'
+        return [result,isFatal]
+    
+    
+    try:
+        json.loads(r[0])
+        result['Notes'] = 'System Message not E2E encrypted'
+        return [result,isFatal]       
+    except:
+        # This is a good thing in this case!
+        
+        # Now try and decrypt the message
+        m = msg.decrypt(r[0],'SYSTEM')
+        if not m:
+            result['Notes'] = 'Could not decrypt'
+            return [result,isFatal]     
+    
+        # Now check we got valid json
+        try:
+            j = json.loads(m)
+            # Finally
+            if "text" not in j or "verb" not in j:
+                result['Notes'] = 'Not valid msg payload'
+                return [result,isFatal]              
+            
+            # Otherwise, we're good
+            result['Result'] = 'Pass'
+            return [result,isFatal]
+            
+        except:
+            result['Notes'] = 'Not valid JSON'
+            return [result,isFatal]              
+    
+
+def test_five(msg):
+    ''' Send a message and ensure it's encrypted in the DB
+    '''
+    
+    result = {'Test' : 'Ensure payloads are encrypted','Result' : 'FAIL', 'Notes': '' }
+    isFatal = False
+    
+    msg.sendMsg('Hello World')
+    
+    CONN,CURSOR = opendb()
+    
+    CURSOR.execute("SELECT msg FROM messages where user=? ORDER BY ts DESC",(STORAGE['room']['User'],))
+    r = CURSOR.fetchone()
+    CONN.close()
+    
+    if not r:
+        result['Notes'] = 'Message not recorded'
+        return [result,isFatal]
+    
+    
+    try:
+        json.loads(r[0])
+        result['Notes'] = 'Message not E2E encrypted'
+        return [result,isFatal]       
+    except:
+        # This is a good thing in this case!
+        
+        # Now try and decrypt the message
+        m = msg.decrypt(r[0],STORAGE['room']['User'])
+        if not m:
+            result['Notes'] = 'Could not decrypt'
+            return [result,isFatal]     
+    
+        # Now check we got valid json
+        try:
+            j = json.loads(m)
+            # Finally
+            if "text" not in j or "verb" not in j:
+                result['Notes'] = 'Not valid msg payload'
+                return [result,isFatal]              
+            
+            # Otherwise, we're good
+            result['Result'] = 'Pass'
+            return [result,isFatal]
+            
+        except:
+            result['Notes'] = 'Not valid JSON'
+            return [result,isFatal]              
+    
+
+
+def test_six(msg):
+    ''' Invite a user
+    '''
+    result = {'Test' : 'Invite a user','Result' : 'FAIL', 'Notes': '' }
+    isFatal = True
+    
+    n = msg.inviteUser('testuser')
+    if not n:
+        result['Notes'] = 'Could not invite testuser'
+        return [result,isFatal]
+    
+    if len(n) < 4:
+        result['Notes'] = 'Client returned too short response'
+        return [result,isFatal]
+        
+    # Otherwise, we've got details for a new user to be able to join
+    #
+    # Store them for a later test
+    
+    STORAGE['testuser'] = {
+        'room':n[0],
+        'pass':"%s:%s" % (n[1],n[2]),
+        'User':n[3]
+        }
+    
+    result['Result'] = "Pass"
+    return [result,isFatal]
+
+
+
+def test_seven(msg):
+    ''' Have the previously invited user join from a new client instance
+    '''
+    result = {'Test' : 'Join as Invited User','Result' : 'FAIL', 'Notes': '' }
+    isFatal = True
+    
+    usermsg = getClientInstance();
+    n = usermsg.joinRoom(STORAGE['testuser']['User'],STORAGE['testuser']['room'],STORAGE['testuser']['pass'])
+    
+    if not n:
+        result['Notes'] = 'Could not join'
+        return [result,isFatal]
+    
+    STORAGE['testuser']['clientInstance'] = usermsg
+    
+    CONN,CURSOR = opendb()
+    
+    # Check the DB to ensure we're now active
+    CURSOR.execute("SELECT * from users where username=? and active=1",(STORAGE['testuser']['User'],))
+    r = CURSOR.fetchone()
+    CONN.close()
+    
+    if not r:
+        result['Notes'] = 'Not Active in DB'
+        return [result,isFatal]
+    
+    # Check we've got a session token
+    if not msg.sesskey:
+        result['Notes'] = 'No Session Key'
+        return [result,isFatal]
+        
+    # Check the client has recorded what it needs to
+    if not msg.room:
+        result['Notes'] = 'Client forgot room'
+        return [result,isFatal]
+
+    if not msg.user:
+        result['Notes'] = 'Client forgot user'
+        return [result,isFatal]
+
+    if not msg.roompass:
+        result['Notes'] = 'Client forgot roompass'
+        return [result,isFatal]
+
+    if not msg.syskey:
+        result['Notes'] = 'No SYSTEM key'
+        return [result,isFatal]
+    
+    
+    result['Result'] = 'Pass'
+    return [result,isFatal]
+
+
+def test_eight(msg):
+    ''' Have the testuser send a message and ensure that the adminuser receives it
+    
+    We first call pollMsg to flush the message queue
+    '''
+    
+    result = {'Test' : 'Send a Message','Result' : 'FAIL', 'Notes': '' }
+    isFatal = False
+        
+    # Poll for messages to clear the queue
+    f = msg.pollForMessage()
+    
+    testpayload = 'The admin is a ....'
+    # Now send a message as test user
+    STORAGE['testuser']['clientInstance'].sendMsg(testpayload)
+    
+    # Now call pollMsg as admin to ensure we receive the message
+    received = msg.pollForMessage()
+
+    # Poll as testuser to clear the queue ready for future tests
+    f = STORAGE['testuser']['clientInstance'].pollForMessage()
+    
+    if len(received) < 1:
+        result['Notes'] = 'No messages received'
+        return [result,isFatal]
+    
+    if len(received[0]) < 2:
+        result['Notes'] = 'Malformed message returned'
+        return [result,isFatal]
+        
+    # Now check the payload
+    #
+    # We need to trim out the timestamp etc
+    r = received[0][1].split('>')
+    m = r[1].lstrip()
+    if testpayload != m:
+        result['Notes'] = 'Incorrect message received: (%s)v(%s)' % (testpayload,m)
+        return [result,isFatal]
+        
+    result['Result'] = 'Pass'
+    return [result,isFatal]
+
+
+
+def test_nine(msg):
+    ''' Try to maliciously invite SYSTEM (as a precursor to logging in as them)
+    
+    Essentially a regression test for LOC-5
+    
+    '''
+    
+    result = {'Test' : 'LOC-5 Try to invite SYSTEM','Result' : 'FAIL', 'Notes': '' }
+    isFatal = False
+        
+    # Poll for messages to clear the queue
+    f = msg.pollForMessage()
+    
+    # Now have the test user try to invite SYSTEM
+    n = STORAGE['testuser']['clientInstance'].inviteUser('SYSTEM')
+    if n:
+        result['Notes'] = 'Allowed to invite SYSTEM'
+        return [result,isFatal]
+    
+    # Now call pollMsg as admin to ensure we received the warning message
+    received = msg.pollForMessage()
+
+    # Poll as testuser to clear the queue ready for future tests
+    f = STORAGE['testuser']['clientInstance'].pollForMessage()
+    
+    if len(received) < 1:
+        result['Notes'] = 'No warning message received'
+        return [result,isFatal]
+    
+    if len(received[0]) < 2:
+        result['Notes'] = 'Malformed message returned'
+        return [result,isFatal]
+        
+    # Now check the payload
+    #
+    # We need to trim out the timestamp etc
+    r = received[0][1].split('>')
+    m = r[1].lstrip()
+    
+    expected = "ALERT: User %s tried to invite SYSTEM" % (STORAGE['testuser']['User'],)
+
+    if expected != m:
+        result['Notes'] = 'Unexpected message received: %s' % (m)
+        return [result,isFatal]
+        
+    result['Result'] = 'Pass'
+    return [result,isFatal]
+
+
+def test_ten(msg):
+    ''' Send a direct message and ensure it's received
+    
+    '''
+    
+    result = {'Test' : 'Send a Direct Message','Result' : 'FAIL', 'Notes': '' }
+    isFatal = False
+    
+    # Poll for messages to clear the queue
+    f = msg.pollForMessage()
+    f = STORAGE['testuser']['clientInstance'].pollForMessage()
+    
+    testpayload = 'Hi Hi'
+    
+    # Now send a direct message from test user to admin
+    n = STORAGE['testuser']['clientInstance'].sendDirectMsg(testpayload,'testadmin')
+    if not n:
+        result['Notes'] = 'Could not send direct message'
+        return [result,isFatal]
+    
+    # Poll as the admin user to ensure it was received
+    received = msg.pollForMessage()
+    
+    # Poll as testuser to clear the queue ready for future tests
+    f = STORAGE['testuser']['clientInstance'].pollForMessage()
+    
+    if len(received) < 1:
+        result['Notes'] = 'No messages received'
+        return [result,isFatal]
+    
+    if len(received[0]) < 2:
+        result['Notes'] = 'Malformed message returned'
+        return [result,isFatal]
+        
+    # Now check the payload
+    #
+    # We need to trim out the timestamp etc
+    r = received[0][1].split('>')
+    m = r[1].lstrip()
+    if testpayload != m:
+        result['Notes'] = 'Incorrect message received: (%s)v(%s)' % (testpayload,m)
+        return [result,isFatal]
+        
+    if "DM" not in r[0]:
+        result['Notes'] = 'Envelope not marked as DM: %s' % (r[0],)
+        return [result,isFatal]
+        
+    result['Result'] = 'Pass'
+    return [result,isFatal]
+    
+
+
+def test_eleven(msg):
+    ''' Invite a new user and then have admin kick them to ensure they're 
+    actually kicked out of the room
+    
+    '''
+    result = {'Test' : 'Invite and kick a user','Result' : 'FAIL', 'Notes': '' }
+    isFatal = True
+    
+    n = msg.inviteUser('testuser2')
+    if not n:
+        result['Notes'] = 'Could not invite testuser2'
+        return [result,isFatal]
+    
+    if len(n) < 4:
+        result['Notes'] = 'Client returned too short response'
+        return [result,isFatal]
+        
+    # Otherwise, we've got details for a new user to be able to join
+    #
+    # Store them for a later test
+    
+    STORAGE['testuser2'] = {
+        'room':n[0],
+        'pass':"%s:%s" % (n[1],n[2]),
+        'User':n[3]
+        }
+    
+    # Create a new instance so we can join as testuser2
+    usermsg = getClientInstance();
+    n = usermsg.joinRoom(STORAGE['testuser2']['User'],STORAGE['testuser2']['room'],STORAGE['testuser2']['pass'])
+    
+    if not n:
+        result['Notes'] = 'User could not join'
+        return [result,isFatal]
+    
+    STORAGE['testuser2']['clientInstance'] = usermsg
+    
+    # Now have the admin kick (but not ban) them
+    msg.kickUser('testuser2',False)
+    
+    
+    # Check that a failure message was written
+    CONN,CURSOR = opendb()
+    CURSOR.execute("SELECT msg FROM failuremsgs where username=?",(STORAGE['testuser2']['User'],))
+    r = CURSOR.fetchone()
+    CONN.close()
+    
+    if not r:
+        result['Notes'] = 'User not notified'
+        return [result,isFatal]  
+        
+    # Poll to receive the failure message and verify it's deleted
+    msgs = STORAGE['testuser2']['clientInstance'].pollForMessage()
+    
+    if len(msgs) < 1:
+        result['Notes'] = "User didn't receive notification"
+        return [result,isFatal]        
+    
+    CONN,CURSOR = opendb()
+    CURSOR.execute("SELECT msg FROM failuremsgs where username=?",(STORAGE['testuser2']['User'],))
+    r = CURSOR.fetchone()
+    CONN.close()
+    
+    if r:
+        result['Notes'] = 'Notification not purged on poll'
+        return [result,isFatal]  
+    
+    # Now check that their session was suspended
+    CONN,CURSOR = opendb()
+    CURSOR.execute("SELECT username FROM users where username=? and active=1",(STORAGE['testuser2']['User'],))
+    r = CURSOR.fetchone()
+    CONN.close()
+    
+    if r:
+        result['Notes'] = 'User still considered active in room'
+        return [result,isFatal]         
+    
+    # Check if their session still exists
+    CONN,CURSOR = opendb()
+    CURSOR.execute("SELECT username FROM sessions where username=?",(STORAGE['testuser2']['User'],))
+    r = CURSOR.fetchone()
+    CONN.close()    
+    if r:
+        result['Notes'] = 'User still has active session'
+        return [result,isFatal]        
+    
+    # Otherwise, looks good
+    result['Result'] = "Pass"
+    return [result,isFatal]
+
+
+def test_twelve(msg):
+    ''' Rejoin as the previously invited user, and then ban them
+    
+    
+    '''
+    result = {'Test' : 'Ban a user','Result' : 'FAIL', 'Notes': '' }
+    isFatal = True
+    
+    # Flush the failedmessage queue
+    f = STORAGE['testuser2']['clientInstance'].pollForMessage()
+    
+    # Re-join as testuser2
+    n = STORAGE['testuser2']['clientInstance'].joinRoom(STORAGE['testuser2']['User'],
+                                                        STORAGE['testuser2']['room'],STORAGE['testuser2']['pass'])
+    
+    if not n:
+        result['Notes'] = 'TestUser2 could not join'
+        return [result,isFatal]
+    
+    # Now have the admin ban them
+    msg.kickUser('testuser2',True)
+    
+    
+    # Check that a failure message was written
+    CONN,CURSOR = opendb()
+    CURSOR.execute("SELECT msg FROM failuremsgs where username=?",(STORAGE['testuser2']['User'],))
+    r = CURSOR.fetchone()
+    CONN.close()
+    
+    if not r:
+        result['Notes'] = 'User not notified'
+        return [result,isFatal]  
+    
+    # Poll to receive the failure message and verify it's deleted
+    msgs = STORAGE['testuser2']['clientInstance'].pollForMessage()
+    
+    if len(msgs) < 1:
+        result['Notes'] = "User didn't receive notification"
+        return [result,isFatal]        
+    
+    CONN,CURSOR = opendb()
+    CURSOR.execute("SELECT msg FROM failuremsgs where username=?",(STORAGE['testuser2']['User'],))
+    r = CURSOR.fetchone()
+    CONN.close()
+    
+    if r:
+        result['Notes'] = 'Notification not purged on poll'
+        return [result,isFatal]  
+   
+    # Now check that their session was suspended
+    CONN,CURSOR = opendb()
+    CURSOR.execute("SELECT username FROM users where username=?",(STORAGE['testuser2']['User'],))
+    r = CURSOR.fetchone()
+    CONN.close()
+    
+    if r:
+        result['Notes'] = 'User still exists for room'
+        return [result,isFatal]         
+    
+    # Check if their session still exists
+    CONN,CURSOR = opendb()
+    CURSOR.execute("SELECT username FROM sessions where username=?",(STORAGE['testuser2']['User'],))
+    r = CURSOR.fetchone()
+    CONN.close()    
+    if r:
+        result['Notes'] = 'User still has active session'
+        return [result,isFatal]        
+    
+    # We don't need this any more
+    del STORAGE['testuser2']
+    
+    # Otherwise, looks good
+    result['Result'] = "Pass"
+    return [result,isFatal]
+
+
+    
+
+
+if __name__ == '__main__':
+    # Start the server
+    proc1 = restartServer(False)
+
+    if not proc1:
+        # Server start failed.
+        # abort, abort, abort
+        exit(proc1,1)
+
+    
+    try:
+        # I don't like generic catchall exceptions
+        # but, we want to make sure we kill the background
+        # process if there is one.
+        results = run_tests()
+    except Exception as e:
+        print traceback.format_exc()
+        print e
+        exit(proc1,1)
+    
+    cols = ['No','Test','Result','Notes']
+    print make_table(cols,results)
+    
+    exit(proc1,0)
+    
+