def test_init(self):
     args = TrialSettings()
     args.vapid_key = os.path.join(tempfile.gettempdir(), uuid.uuid4().hex)
     vapid = Vapid()
     vapid.generate_keys()
     vapid.save_key(args.vapid_key)
     client = PushClient(loop=self.loop, args=args)
     assert (client.vapid.public_key.public_numbers().encode_point ==
             vapid.public_key.public_numbers().encode_point)
     os.unlink(args.vapid_key)
class RunnerHarness(object):
    """Runs multiple instances of a single scenario

    Running an instance of the scenario is triggered with :meth:`run`. It
    will run to completion or possibly forever.

    """
    def __init__(self,
                 load_runner,
                 websocket_url,
                 statsd_client,
                 scenario,
                 endpoint=None,
                 endpoint_ssl_cert=None,
                 endpoint_ssl_key=None,
                 *scenario_args,
                 **scenario_kw):
        logging.debug("Connecting to {}".format(websocket_url))
        self._factory = WebSocketClientFactory(
            websocket_url, headers={"Origin": "http://localhost:9000"})
        self._factory.protocol = WSClientProtocol
        self._factory.harness = self
        if websocket_url.startswith("wss"):
            self._factory_context = ssl.ClientContextFactory()
        else:
            self._factory_context = None

        # somewhat bogus encryption headers
        self._crypto_key = "keyid=p256dh;dh=c2VuZGVy"
        self._encryption = "keyid=p256dh;salt=XZwpw6o37R-6qoZjw6KwAw"

        # Processor and Websocket client vars
        self._scenario = scenario
        self._scenario_args = scenario_args
        self._scenario_kw = scenario_kw
        self._processors = 0
        self._ws_clients = {}
        self._connect_waiters = deque()
        self._load_runner = load_runner
        self._stat_client = statsd_client
        self._vapid = Vapid()
        if "vapid_private_key" in self._scenario_kw:
            self._vapid = Vapid(
                private_key=self._scenario_kw.get("vapid_private_key"))
        else:
            self._vapid.generate_keys()
        self._claims = ()
        if "vapid_claims" in self._scenario_kw:
            self._claims = self._scenario_kw.get("vapid_claims")

        self._endpoint = urlparse.urlparse(endpoint) if endpoint else None
        self._agent = None
        if endpoint_ssl_cert:
            self._agent = Agent(reactor,
                                contextFactory=UnverifiedHTTPS(
                                    endpoint_ssl_cert, endpoint_ssl_key))
            if hasattr(endpoint_ssl_cert, 'seek'):
                endpoint_ssl_cert.seek(0)
            if endpoint_ssl_key and hasattr(endpoint_ssl_key, 'seek'):
                endpoint_ssl_key.seek(0)

    def run(self):
        """Start registered scenario"""
        # Create the processor and start it
        processor = CommandProcessor(self._scenario, self._scenario_args,
                                     self._scenario_kw, self)
        processor.run()
        self._processors += 1

    def spawn(self, test_plan):
        """Spawn a new test plan"""
        self._load_runner.spawn(test_plan)

    def connect(self, processor):
        """Start a connection for a processor and queue it for when the
        connection is available"""
        self._connect_waiters.append(processor)
        connectWS(self._factory, contextFactory=self._factory_context)

    def send_notification(self,
                          processor,
                          url,
                          data,
                          headers=None,
                          claims=None):
        """Send out a notification to a url for a processor

        This uses the older `aesgcm` format.

        """
        if not headers:
            headers = {}
        url = url.encode("utf-8")
        if "TTL" not in headers:
            headers["TTL"] = "0"
        crypto_key = self._crypto_key
        if claims is None:
            claims = ()
        claims = claims or self._claims
        if self._vapid and claims:
            if isinstance(claims, str):
                claims = json.loads(claims)
            if "aud" not in claims:
                # Construct a valid `aud` from the known endpoint
                parsed = urlparse.urlparse(url)
                claims["aud"] = "{scheme}://{netloc}".format(
                    scheme=parsed.scheme, netloc=parsed.netloc)
                log.msg("Setting VAPID 'aud' to {}".format(claims["aud"]))
            headers.update(self._vapid.sign(claims, self._crypto_key))
        if data:
            headers.update({
                "Content-Type": "application/octet-stream",
                "Content-Encoding": "aesgcm",
                "Crypto-key": crypto_key,
                "Encryption": self._encryption,
            })

        d = treq.post(url,
                      data=data,
                      headers=headers,
                      allow_redirects=False,
                      agent=self._agent)
        d.addCallback(self._sent_notification, processor)
        d.addErrback(self._error_notif, processor)

    def _sent_notification(self, result, processor):
        d = result.content()
        d.addCallback(self._finished_notification, result, processor)
        d.addErrback(self._error_notif, result, processor)

    def _finished_notification(self, result, response, processor):
        # Give the fully read content and response to the processor
        processor._send_command_result((response, result))

    def _error_notif(self, failure, processor):
        # Send the failure back
        processor._send_command_result((None, failure))

    def add_client(self, ws_client):
        """Register a new websocket connection and return a waiting
        processor"""
        try:
            processor = self._connect_waiters.popleft()
        except IndexError:
            log.msg("No waiting processors for new client connection.")
            ws_client.sendClose()
        else:
            self._ws_clients[ws_client] = processor
            return processor

    def remove_client(self, ws_client):
        """Remove a websocket connection from the client registry"""
        processor = self._ws_clients.pop(ws_client, None)
        if not processor:
            # Possible failed connection, if we have waiting processors still
            # then try a new connection
            if len(self._connect_waiters):
                connectWS(self._factory, contextFactory=self._factory_context)
            return

    def remove_processor(self):
        """Remove a completed processor"""
        self._processors -= 1

    def timer(self, name, duration):
        """Record a metric timer if we have a statsd client"""
        self._stat_client.timing(name, duration)

    def counter(self, name, count=1):
        """Record a counter if we have a statsd client"""
        self._stat_client.increment(name, count)
class PushClient(object):
    """Smoke Test the Autopush push server"""
    def __init__(self, args, loop,
                 tasks: List[List[Union[AnyStr, Dict]]]=None):
        self.config = args
        self.loop = loop
        self.connection = None
        self.pushEndpoint = None
        self.channelID = None
        self.notifications = []
        self.uaid = None
        self.recv = []
        self.tasks = tasks or []
        self.output = None
        self.vapid_cache = {}
        if args.vapid_key:
            self.vapid = Vapid().from_file(args.vapid_key)
        else:
            self.vapid = Vapid()
            self.vapid.generate_keys()
        self.tls_conn = None
        if args.partner_endpoint_cert:
            if os.path.isfile(args.partner_endpoint_cert):
                context = ssl.create_default_context(
                    cafile=args.partner_endpoint_cert)
            else:
                context = ssl.create_default_context(
                    cadata=args.partner_endpoint_cert)
            context.verify_mode = ssl.CERT_REQUIRED
            context.check_hostname = False
            self.tls_conn = aiohttp.TCPConnector(ssl_context=context)

    def _fix_endpoint(self, endpoint: str) -> str:
        """Adjust the endpoint if needed"""
        if self.config.partner_endpoint:
            orig_path = urlparse(endpoint).path
            partner = urlparse(self.config.partner_endpoint)
            return "{scheme}://{host}{path}".format(
                scheme=partner.scheme,
                host=partner.netloc,
                path=orig_path)
        return endpoint

    def _cache_sign(self, claims: Dict[str, str]) -> Dict[str, str]:
        """Pull a VAPID header from the cache or sign the new header

        :param claims: list of VAPID claims.
        :returns: dictionary of VAPID headers.

        """
        vhash = hashlib.sha1()
        vhash.update(json.dumps(claims).encode())
        key = vhash.digest()
        if key not in self.vapid_cache:
            self.vapid_cache[key] = self.vapid.sign(claims)
        return self.vapid_cache[key]

    async def _next_task(self):
        """Tasks are shared between active "cmd_*" commands
        and async "recv_*" events. Since both are reading off
        the same stack, we centralize that here.

        """
        try:
            task = self.tasks.pop(0)
            logging.debug(">>> cmd_{}".format(task[0]))
            await getattr(self, "cmd_" + task[0])(**(task[1]))
            return True
        except IndexError:
            await self.cmd_done()
            return False
        except PushException:
            raise
        except AttributeError:
            raise PushException("Invalid command: {}".format(task[0]))
        except Exception:  # pragma nocover
            traceback.print_exc()
            raise

    async def run(self,
                  server: str="wss://push.services.mozilla.com",
                  tasks: List[List[Union[AnyStr, Dict]]]=None):
        """Connect to a remote server and execute the tasks

        :param server: URL to the Push Server
        :param tasks: List of tasks and arguments to run

        """
        if tasks:
            self.tasks = tasks
        if not self.connection:
            await self.cmd_connect(server)
        while await self._next_task():
            pass

    async def process(self, message: Dict[str, Any]):
        """Process an incoming websocket message

        :param message: JSON message content

        """
        mtype = "recv_" + message.get('messageType').lower()
        try:
            await getattr(self, mtype)(**message)
        except AttributeError as ex:
            raise PushException(
                "Unknown messageType: {}".format(mtype)) from ex

    async def receiver(self):
        """Receiver handler for websocket messages

        """
        try:
            while self.connection:
                message = await self.connection.recv()
                print("<<< {}".format(message))
                await self.process(json.loads(message))
        except websockets.exceptions.ConnectionClosed:
            output_msg(out=self.output, status="Websocket Connection closed")

    # Commands:::

    async def _send(self, no_recv: bool=False, **msg):
        """Send a message out the websocket connection

        :param no_recv: Flag to indicate if response is expected
        :param msg: message content
        :return:

        """
        output_msg(out=self.output, flow="output", msg=msg)
        try:
            await self.connection.send(json.dumps(msg))
            if no_recv:
                return
            message = await self.connection.recv()
            await self.process(json.loads(message))
        except websockets.exceptions.ConnectionClosed:
            pass

    async def cmd_connect(self, server: str=None, **kwargs):
        """Connect to a remote websocket server

        :param server: Websocket url
        :param kwargs: ignored

        """
        srv = self.config.server or server
        output_msg(out=self.output, status="Connecting to {}".format(srv))
        self.connection = await websockets.connect(srv)
        self.recv.append(asyncio.ensure_future(self.receiver()))

    async def cmd_close(self, **kwargs):
        """Close the websocket connection (if needed)

        :param kwargs: ignored

        """
        output_msg(out=self.output, status="Closing socket connection")
        if self.connection and self.connection.state == 1:
            try:
                for recv in self.recv:
                    recv.cancel()
                self.recv = []
                await self.connection.close()
            except (websockets.exceptions.ConnectionClosed,
                    futures.CancelledError):
                pass

    async def cmd_sleep(self, period: int=5, **kwargs):
        output_msg(out=self.output, status="Sleeping...")
        await asyncio.sleep(period)

    async def cmd_hello(self, uaid: str=None, **kwargs):
        """Send a websocket "hello" message

        :param uaid: User Agent ID (if reconnecting)

        """
        if not self.connection or self.connection.state != 1:
            await self.cmd_connect()
        output_msg(out=self.output, status="Sending Hello")
        args = dict(messageType="hello", use_webpush=1, **kwargs)
        if uaid:
            args['uaid'] = uaid
        elif self.uaid:
            args['uaid'] = self.uaid
        await self._send(**args)

    async def cmd_ack(self,
                      channelID: str=None,
                      version: str=None,
                      timeout: int=60,
                      **kwargs):
        """Acknowledge a previous mesage

        :param channelID: Channel to acknowledge
        :param version: Version string for message to acknowledge
        :param kwargs: Additional optional arguments
        :param timeout: Time to wait for notifications (used by testing)

        """
        timeout = timeout * 2
        while not self.notifications:
            output_msg(
                out=self.output,
                status="No notifications recv'd, Sleeping...")
            await asyncio.sleep(0.5)
            timeout -= 1
            if timeout < 1:
                raise PushException("Timeout waiting for messages")

        self.notifications.reverse()
        for notif in self.notifications:
            output_msg(
                out=self.output,
                status="Sending ACK",
                channelID=channelID or notif['channelID'],
                version=version or notif['version'])
            await self._send(messageType="ack",
                             channelID=channelID or notif['channelID'],
                             version=version or notif['version'],
                             no_recv=True)
        self.notifications = []

    async def cmd_register(self, channelID: str=None,
                           key: str=None, **kwargs):
        """Register a new ChannelID

        :param channelID: UUID for the channel to register
        :param key: applicationServerKey for a restricted access channel
        :param kwargs: additional optional arguments
        :return:

        """
        output_msg(
            out=self.output,
            status="Sending new channel registration")
        channelID = channelID or self.channelID or str(uuid.uuid4())
        args = dict(messageType='register',
                    channelID=channelID)
        if key:
            args[key] = key
        args.update(kwargs)
        await self._send(**args)

    async def cmd_done(self, **kwargs):
        """Close all connections and mark as done

        :param kwargs: ignored
        :return:

        """
        output_msg(
            out=self.output,
            status="done")
        await self.cmd_close()
        await self.connection.close_connection()

    """
    recv_* commands handle incoming responses.Since they are asynchronous
    and need to trigger follow-up tasks, they each will need to pull and
    process the next task.
    """

    async def recv_hello(self, **msg: Dict[str, Any]):
        """Process a received "hello"

        :param msg: body of response
        :return:

        """
        assert(msg['status'] == 200)
        try:
            self.uaid = msg['uaid']
            await self._next_task()
        except KeyError as ex:
            raise PushException from ex

    async def recv_register(self, **msg):
        """Process a received registration message

        :param msg: body of response
        :return:

        """
        assert(msg['status'] == 200)
        self.pushEndpoint = self._fix_endpoint(msg['pushEndpoint'])
        self.channelID = msg['channelID']
        output_msg(
            out=self.output,
            flow="input",
            msg=dict(
                message="register",
                channelID=self.channelID,
                pushEndpoint=self.pushEndpoint))
        await self._next_task()

    async def recv_notification(self, **msg):
        """Process a received notification message.
        This event does NOT trigger the next command in the stack.

        :param msg: body of response

        """
        def repad(string):
            return string + '===='[len(msg['data']) % 4:]

        msg['_decoded_data'] = base64.urlsafe_b64decode(
            repad(msg['data'])).decode()
        output_msg(
            out=self.output,
            flow="input",
            message="notification",
            msg=msg)
        self.notifications.append(msg)
        await self.cmd_ack()

    async def _post(self, session, url: str, data: bytes):
        """Post a message to the endpoint

        :param session: async session object
        :param url: pushEndpoint
        :param data: data to send
        :return:

        """
        # print ("Fetching {}".format(url))
        with aiohttp.Timeout(10, loop=session.loop):
            return await session.post(url=url,
                                      data=data)

    async def _post_session(self, url: str,
                            headers: Dict[str, str],
                            data: bytes):
        """create a session to send the post message to the endpoint

        :param url: pushEndpoint
        :param headers: dictionary of headers
        :param data: body of the content to send

        """

        async with aiohttp.ClientSession(
                loop=self.loop,
                headers=headers,
                read_timeout=30,
                connector=self.tls_conn,
        ) as session:
            reply = await self._post(session, url, data)
            return reply

    async def cmd_push(self, data: bytes=None,
                       headers: Dict[str, str]=None,
                       claims: Dict[str, str]=None):
        """Push data to the pushEndpoint

        :param data: message content
        :param headers: dictionary of headers
        :param claims: VAPID claims
        :return:

        """
        if not self.pushEndpoint:
            raise PushException("No Endpoint, no registration?")
        if not headers:
            headers = {}
        if claims:
            headers.update(self._cache_sign(claims))
        output_msg(
            out=self.output,
            status="Pushing message",
            msg=repr(data))
        if data and 'content-encoding' not in headers:
            headers.update({
                    "content-encoding": "aesgcm128",
                    "encryption": "salt=test",
                    "encryption-key": "dh=test",
                })
        result = await self._post_session(self.pushEndpoint, headers, data)
        body = await result.text()
        output_msg(
            out=self.output,
            flow="http-out",
            pushEndpoint=self.pushEndpoint,
            headers=headers,
            data=repr(data),
            result="{}: {}".format(result.status, body))