#       Licensed to the Apache Software Foundation (ASF) under one
#       or more contributor license agreements.  See the NOTICE file
#       distributed with this work for additional information
#       regarding copyright ownership.  The ASF licenses this file
#       to you 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.

import json
import hmac
import hashlib
import datetime as dt

from mock import Mock, MagicMock, patch, call
import pytest
from formencode import Invalid

from allura.tests.test_helpers import httpbin_domain
from ming.odm import session
from tg import tmpl_context as c
from tg import config

from allura import model as M
from allura.lib import helpers as h
from allura.webhooks import (
    WebhookValidator,
    WebhookController,
    send_webhook,
    RepoPushWebhookSender,
    SendWebhookHelper,
)
from allura.tests import decorators as td
from alluratest.controller import (
    setup_basic_test,
    TestController,
    TestRestApiBase,
)


# important to be distinct from 'test' and 'test2' which ForgeGit and
# ForgeImporter use, so that the tests can run in parallel and not clobber each
# other
test_project_with_repo = 'adobe-1'
with_git = td.with_tool(test_project_with_repo, 'git', 'src', 'Git')
with_git2 = td.with_tool(test_project_with_repo, 'git', 'src2', 'Git2')


class TestWebhookBase:
    def setup_method(self, method):
        setup_basic_test()
        self.patches = self.monkey_patch()
        for p in self.patches:
            p.start()
        self.setup_with_tools()
        self.project = M.Project.query.get(shortname=test_project_with_repo)
        self.git = self.project.app_instance('src')
        self.wh = M.Webhook(
            type='repo-push',
            app_config_id=self.git.config._id,
            hook_url='http://httpbin.org/post',
            secret='secret')
        session(self.wh).flush(self.wh)

    def teardown_method(self, method):
        for p in self.patches:
            p.stop()

    @with_git
    def setup_with_tools(self):
        pass

    def monkey_patch(self):
        # we don't need actual repo here, and this avoids test conflicts when
        # running in parallel
        repo_init = patch.object(M.Repository, 'init', autospec=True)
        return [repo_init]


class TestValidators(TestWebhookBase):
    @with_git2
    def test_webhook_validator(self):
        sender = Mock(type='repo-push')
        app = self.git
        invalid_app = self.project.app_instance('src2')
        v = WebhookValidator(sender=sender, app=app, not_empty=True)
        with pytest.raises(Invalid) as cm:
            v.to_python(None)
        assert cm.value.msg == 'Please enter a value'
        with pytest.raises(Invalid) as cm:
            v.to_python('invalid id')
        assert cm.value.msg == 'Invalid webhook'

        wh = M.Webhook(type='invalid type',
                       app_config_id=invalid_app.config._id,
                       hook_url='http://hooks.slack.com',
                       secret='secret')
        session(wh).flush(wh)
        # invalid type
        with pytest.raises(Invalid) as cm:
            v.to_python(wh._id)
        assert cm.value.msg == 'Invalid webhook'

        wh.type = 'repo-push'
        session(wh).flush(wh)
        # invalild app
        with pytest.raises(Invalid) as cm:
            v.to_python(wh._id)
        assert cm.value.msg == 'Invalid webhook'

        wh.app_config_id = app.config._id
        session(wh).flush(wh)
        assert v.to_python(wh._id) == wh
        assert v.to_python(str(wh._id)) == wh


class TestWebhookController(TestController):

    def setup_method(self, method):
        super().setup_method(method)
        self.patches = self.monkey_patch()
        for p in self.patches:
            p.start()
        self.setup_with_tools()
        self.project = M.Project.query.get(shortname=test_project_with_repo)
        self.git = self.project.app_instance('src')
        self.url = str(self.git.admin_url + 'webhooks')

    def teardown_method(self, method):
        super().teardown_method(method)
        for p in self.patches:
            p.stop()

    @with_git
    def setup_with_tools(self):
        pass

    def monkey_patch(self):
        gen_secret = patch.object(
            WebhookController,
            'gen_secret',
            return_value='super-secret',
            autospec=True)
        # we don't need actual repo here, and this avoids test conflicts when
        # running in parallel
        repo_init = patch.object(M.Repository, 'init', autospec=True)
        return [gen_secret, repo_init]

    def create_webhook(self, data, url=None):
        url = url or self.url
        r = self.app.post(url + '/repo-push/create', data)
        wf = json.loads(self.webflash(r))
        assert wf['status'] == 'ok'
        assert wf['message'] == 'Created successfully'
        return r

    def find_error(self, r, field, msg, form_type='create'):
        form = r.html.find('form', attrs={'action': form_type})
        if field == '_the_form':
            error = form.find_previous('div', attrs={'class': 'error'})
        else:
            error = form.find('input', attrs={'name': field})
            error = error.find_next('div', attrs={'class': 'error'})
        if error:
            assert msg in error.getText()
        else:
            assert False, 'Validation error not found'

    def test_AAAA_WORKAROUND__edit(self):
        """
        This must run first in this test class for unknown reasons ever since
            https://github.com/TurboGears/tg2/commit/02fb49b14e70fdd8ac16973488fb3637e5e59114

        If any test runs the self.app.post from create_webhook before this one, then this test will fail on:
            with td.audits(msg):
                r = form.submit()
        because WebhookValidator's `value` will be "create" instead of an objectid str

        Maybe something to do with WebhookControllerMeta setup of `validate` decorators?
        """
        data1 = {'url': 'http://httpbin.org/post',
                 'secret': 'secret'}
        data2 = {'url': 'http://example.com/hook',
                 'secret': 'secret2'}
        self.create_webhook(data1).follow()
        self.create_webhook(data2).follow()
        assert M.Webhook.query.find().count() == 2
        wh1 = M.Webhook.query.get(hook_url=data1['url'])
        r = self.app.get(self.url + '/repo-push/%s' % wh1._id)
        form = r.forms[0]
        assert form['url'].value == data1['url']
        assert form['secret'].value == data1['secret']
        assert form['webhook'].value == str(wh1._id)
        form['url'] = 'http://host.org/hook'
        form['secret'] = 'new secret'
        msg = 'edit webhook repo-push\n{} => {}\n{}'.format(
            data1['url'], form['url'].value, 'secret changed')
        with td.audits(msg):
            r = form.submit()
        wf = json.loads(self.webflash(r))
        assert wf['status'] == 'ok'
        assert wf['message'] == 'Edited successfully'
        assert M.Webhook.query.find().count() == 2
        wh1 = M.Webhook.query.get(_id=wh1._id)
        assert wh1.hook_url == 'http://host.org/hook'
        assert wh1.app_config_id == self.git.config._id
        assert wh1.secret == 'new secret'
        assert wh1.type == 'repo-push'

        # Duplicates
        r = self.app.get(self.url + '/repo-push/%s' % wh1._id)
        form = r.forms[0]
        form['url'] = data2['url']
        r = form.submit()
        self.find_error(r, '_the_form',
                        '"repo-push" webhook already exists for Git http://example.com/hook',
                        form_type='edit')

    def test_access(self):
        self.app.get(self.url + '/repo-push/')
        self.app.get(self.url + '/repo-push/',
                     extra_environ={'username': 'test-user'},
                     status=403)
        r = self.app.get(self.url + '/repo-push/',
                         extra_environ={'username': '*anonymous'},
                         status=302)
        assert (r.location ==
                'http://localhost/auth/'
                '?return_to=%2Fadobe%2Fadobe-1%2Fadmin%2Fsrc%2Fwebhooks%2Frepo-push%2F')

    def test_invalid_hook_type(self):
        self.app.get(self.url + '/invalid-hook-type/', status=404)

    def test_create(self):
        assert M.Webhook.query.find().count() == 0
        r = self.app.get(self.url)
        assert '<h1>repo-push</h1>' in r
        assert 'http://httpbin.org/post' not in r
        data = {'url': 'http://httpbin.org/post',
                'secret': ''}
        msg = 'add webhook repo-push {} {}'.format(
            data['url'], self.git.config.url())
        with td.audits(msg):
            r = self.create_webhook(data).follow()
        assert 'http://httpbin.org/post' in r

        hooks = M.Webhook.query.find().all()
        assert len(hooks) == 1
        assert hooks[0].type == 'repo-push'
        assert hooks[0].hook_url == 'http://httpbin.org/post'
        assert hooks[0].app_config_id == self.git.config._id
        assert hooks[0].secret == 'super-secret'

        # Try to create duplicate
        with td.out_audits(msg):
            r = self.app.post(self.url + '/repo-push/create', data)
        self.find_error(r, '_the_form',
                        '"repo-push" webhook already exists for Git http://httpbin.org/post')
        assert M.Webhook.query.find().count() == 1

    def test_create_limit_reached(self):
        assert M.Webhook.query.find().count() == 0
        limit = json.dumps({'git': 1})
        with h.push_config(config, **{'webhook.repo_push.max_hooks': limit}):
            data = {'url': 'http://httpbin.org/post',
                    'secret': ''}
            r = self.create_webhook(data).follow()
            assert M.Webhook.query.find().count() == 1

            r = self.app.post(self.url + '/repo-push/create', data)
            wf = json.loads(self.webflash(r))
            assert wf['status'] == 'error'
            assert (
                wf['message'] ==
                'You have exceeded the maximum number of webhooks '
                'you are allowed to create for this project/app')
            assert M.Webhook.query.find().count() == 1

    def test_create_validation(self):
        assert M.Webhook.query.find().count() == 0
        r = self.app.post(
            self.url + '/repo-push/create', {}, status=404)

        data = {'url': '', 'secret': ''}
        r = self.app.post(self.url + '/repo-push/create', data)
        self.find_error(r, 'url', 'Please enter a value')

    @pytest.mark.parametrize('url', [
        'http://localhost/hook',
        'http://127.0.0.1/hook',
        'http://10.0.0.1/hook',
        'http://192.168.1.1/hook',
        'https://169.254.169.254/',
    ])
    def test_create_ssrf_private_url_rejected(self, url):
        r = self.app.post(self.url + '/repo-push/create', {'url': url, 'secret': ''})
        self.find_error(r, 'url', 'Invalid URL')

    @patch.dict(config, {'urlopen_allow_internal_hostnames': 'true'})
    def test_create_private_url_allowed(self):
        with td.audits(r'add webhook repo-push'):
            self.app.post(self.url + '/repo-push/create', {'url': 'http://localhost/hook', 'secret': ''})

    @pytest.mark.parametrize('url', [
        'http://localhost/hook',
        'http://127.0.0.1/hook',
        'https://10.0.0.1/hook',
    ])
    def test_edit_ssrf_private_url_rejected(self, url):
        data = {'url': 'http://httpbin.org/post', 'secret': 'secret'}
        self.create_webhook(data).follow()
        wh = M.Webhook.query.get(hook_url=data['url'], type='repo-push')
        edit_data = {'url': url, 'secret': '', 'webhook': str(wh._id)}
        r = self.app.post(self.url + '/repo-push/edit', edit_data)
        self.find_error(r, 'url', 'Invalid URL', 'edit')

    def test_edit_validation(self):
        invalid = M.Webhook(
            type='invalid type',
            app_config_id=None,
            hook_url='http://httpbin.org/post',
            secret='secret')
        session(invalid).flush(invalid)
        self.app.get(self.url + '/repo-push/%s' % invalid._id, status=404)

        data = {'url': 'http://httpbin.org/post',
                'secret': 'secret'}
        self.create_webhook(data).follow()
        wh = M.Webhook.query.get(hook_url=data['url'], type='repo-push')

        # invalid id in hidden field, just in case
        r = self.app.get(self.url + '/repo-push/%s' % wh._id)
        data = {k: v[0].value for (k, v) in r.forms[0].fields.items() if k}
        data['webhook'] = str(invalid._id)
        self.app.post(self.url + '/repo-push/edit', data, status=404)

        # empty values
        data = {'url': '', 'secret': '', 'webhook': str(wh._id)}
        r = self.app.post(self.url + '/repo-push/edit', data)
        self.find_error(r, 'url', 'Please enter a value', 'edit')

    def test_delete(self):
        data = {'url': 'http://httpbin.org/post',
                'secret': 'secret'}
        self.create_webhook(data).follow()
        assert M.Webhook.query.find().count() == 1
        wh = M.Webhook.query.get(hook_url=data['url'])
        data = {'webhook': str(wh._id)}
        msg = 'delete webhook repo-push {} {}'.format(
            wh.hook_url, self.git.config.url())
        with td.audits(msg):
            r = self.app.post(self.url + '/repo-push/delete', data)
        assert r.json == {'status': 'ok'}
        assert M.Webhook.query.find().count() == 0

    def test_delete_validation(self):
        invalid = M.Webhook(
            type='invalid type',
            app_config_id=None,
            hook_url='http://httpbin.org/post',
            secret='secret')
        session(invalid).flush(invalid)
        assert M.Webhook.query.find().count() == 1

        data = {'webhook': ''}
        self.app.post(self.url + '/repo-push/delete', data, status=404)

        data = {'webhook': str(invalid._id)}
        self.app.post(self.url + '/repo-push/delete', data, status=404)
        assert M.Webhook.query.find().count() == 1

    @with_git2
    def test_list_webhooks(self):
        git2 = self.project.app_instance('src2')
        url2 = str(git2.admin_url + 'webhooks')
        data1 = {'url': 'http://httpbin.org/post',
                 'secret': 'secret'}
        data2 = {'url': 'http://example.com/',
                 'secret': 'secret2'}
        data3 = {'url': 'http://1.2.3.4/',
                 'secret': 'secret3'}
        self.create_webhook(data1).follow()
        self.create_webhook(data2).follow()
        self.create_webhook(data3, url=url2).follow()
        wh1 = M.Webhook.query.get(hook_url=data1['url'])
        wh2 = M.Webhook.query.get(hook_url=data2['url'])

        r = self.app.get(self.url)
        assert '<h1>repo-push</h1>' in r
        rows = r.html.find('table').find_all('tr')
        assert len(rows) == 2
        rows = sorted((self._format_row(row) for row in rows), key=lambda rows: rows[0]['text'])
        expected_rows = sorted([
            [{'text': wh1.hook_url},
             {'text': wh1.secret},
             {'href': self.url + '/repo-push/' + str(wh1._id),
              'text': 'Edit'},
             {'href': self.url + '/repo-push/delete',
              'data-id': str(wh1._id)}],
            [{'text': wh2.hook_url},
             {'text': wh2.secret},
             {'href': self.url + '/repo-push/' + str(wh2._id),
              'text': 'Edit'},
             {'href': self.url + '/repo-push/delete',
              'data-id': str(wh2._id)}],
        ], key=lambda rows: rows[0]['text'])
        assert rows == expected_rows
        # make sure webhooks for another app is not visible
        assert 'http://another-app.org/' not in r
        assert 'secret3' not in r

    def _format_row(self, row):
        def link(td):
            a = td.find('a')
            return {'href': a.get('href'), 'text': a.getText()}

        def text(td):
            return {'text': td.getText()}

        def delete_btn(td):
            a = td.find('a')
            return {'href': a.get('href'), 'data-id': a.get('data-id')}

        tds = row.find_all('td')
        return [text(tds[0]), text(tds[1]), link(tds[2]), delete_btn(tds[3])]


class TestSendWebhookHelper(TestWebhookBase):
    def setup_method(self, method):
        super().setup_method(method)
        self.payload = {'some': ['data', 23]}
        self.h = SendWebhookHelper(self.wh, self.payload)

    def test_timeout(self):
        assert self.h.timeout == 30
        with h.push_config(config, **{'webhook.timeout': 10}):
            assert self.h.timeout == 10

    def test_retries(self):
        assert self.h.retries == [60, 120, 240]
        with h.push_config(config, **{'webhook.retry': '1 2 3 4 5 6'}):
            assert self.h.retries == [1, 2, 3, 4, 5, 6]

    def test_sign(self):
        json_payload = json.dumps(self.payload)
        signature = hmac.new(
            self.wh.secret.encode('utf-8'),
            json_payload.encode('utf-8'),
            hashlib.sha1)
        signature = 'sha1=' + signature.hexdigest()
        assert self.h.sign(json_payload) == signature

    def test_log_msg(self):
        assert (
            self.h.log_msg('OK') ==
            'OK: repo-push http://httpbin.org/post /adobe/adobe-1/src/')
        response = Mock(
            status=500,
            reason='that is why',
            headers={'Content-Type': 'application/json'})
        response.read.return_value = b'that is why'
        assert (
            self.h.log_msg('Error', response=response) ==
            "Error: repo-push http://httpbin.org/post /adobe/adobe-1/src/ 500 "
            "that is why {'Content-Type': 'application/json'}")

    @patch('allura.webhooks.SendWebhookHelper', autospec=True)
    def test_send_webhook_task(self, swh):
        send_webhook(self.wh._id, self.payload)
        swh.assert_called_once_with(self.wh, self.payload)

    @patch('urllib.request.Request', autospec=True)
    @patch('urllib.request.urlopen', autospec=True)
    @patch('allura.webhooks.log', autospec=True)
    def test_send(self, log, urlopen, Request):
        urlopen.return_value = Mock(status=200)
        self.h.sign = Mock(return_value='sha1=abc')
        self.h.send()
        headers = {'content-type': 'application/json',
                   'User-Agent': 'Allura Webhook (https://allura.apache.org/)',
                   'X-Allura-Signature': 'sha1=abc'}
        Request.assert_called_once_with(
            self.wh.hook_url,
            data=json.dumps(self.payload).encode('utf-8'),
            headers=headers,
        )
        urlopen.assert_called_once_with(Request.return_value, timeout=30)
        log.info.assert_called_once_with(
            'Webhook successfully sent: {} {} {}'.format(
                self.wh.type, self.wh.hook_url, self.wh.app_config.url()))

    @patch('allura.webhooks.time', autospec=True)
    @patch('urllib.request.urlopen', autospec=True)
    @patch('allura.webhooks.log', autospec=True)
    def test_send_error_response_status(self, log, urlopen, time):
        urlopen.return_value = Mock(status=500, headers='Content-Type: text/plain')
        urlopen.return_value.read.return_value = b'internal error'
        self.h.send()
        assert urlopen.call_count == 4  # initial call + 3 retries
        assert (time.sleep.call_args_list ==
                [call(60), call(120), call(240)])
        assert log.info.call_args_list == [
            call('Retrying webhook in: %s', [60, 120, 240]),
            call('Retrying webhook in %s seconds', 60),
            call('Retrying webhook in %s seconds', 120),
            call('Retrying webhook in %s seconds', 240)]
        assert log.error.call_count == 4
        log.error.assert_called_with(
            "Webhook send error: {} {} {} 500 internal error Content-Type: text/plain".format(
                self.wh.type, self.wh.hook_url,
                self.wh.app_config.url()
            )
        )

    @patch('allura.webhooks.time', autospec=True)
    @patch('urllib.request.urlopen', autospec=True)
    @patch('allura.webhooks.log', autospec=True)
    def test_send_error_no_retries(self, log, urlopen, time):
        urlopen.return_value = Mock(status=500, headers='Content-Type: text/plain')
        urlopen.return_value.read.return_value = b'internal error'
        with h.push_config(config, **{'webhook.retry': ''}):
            self.h.send()
            assert urlopen.call_count == 1
            assert time.call_count == 0
            log.info.assert_called_once_with('Retrying webhook in: %s', [])
            assert log.error.call_count == 1
            log.error.assert_called_with(
                "Webhook send error: {} {} {} 500 internal error Content-Type: text/plain".format(
                    self.wh.type, self.wh.hook_url,
                    self.wh.app_config.url()
                )
            )

    @pytest.mark.parametrize('url', [
        'http://localhost/hook',
        'http://127.0.0.1/hook',
        'http://10.0.0.1/hook',
        'http://192.168.1.1/hook',
    ])
    @patch('allura.webhooks.log', autospec=True)
    def test_send_ssrf_private_url_blocked(self, log, url):
        result = self.h._send(url, json.dumps(self.payload), {})
        assert result is False
        log.exception.assert_called_once()

    @patch('allura.webhooks.log', autospec=True)
    def test_send_redir_private_url(self, log):
        url = f'https://{httpbin_domain}/redirect-to?url=http://localhost'
        result = self.h._send(url, json.dumps(self.payload), {})
        assert result is False
        log.exception.assert_called_once()


class TestRepoPushWebhookSender(TestWebhookBase):
    @patch('allura.webhooks.send_webhook', autospec=True)
    def test_send(self, send_webhook):
        sender = RepoPushWebhookSender()
        sender.get_payload = Mock()
        with h.push_config(c, app=self.git):
            sender.send(dict(arg1=1, arg2=2))
        send_webhook.post.assert_called_once_with(
            self.wh._id,
            sender.get_payload.return_value)

    @patch('allura.webhooks.send_webhook', autospec=True)
    def test_send_with_list(self, send_webhook):
        sender = RepoPushWebhookSender()
        sender.get_payload = Mock(side_effect=[1, 2])
        self.wh.enforce_limit = Mock(return_value=True)
        with h.push_config(c, app=self.git):
            sender.send([dict(arg1=1, arg2=2), dict(arg1=3, arg2=4)])
        assert send_webhook.post.call_count == 2
        assert (send_webhook.post.call_args_list ==
                [call(self.wh._id, 1), call(self.wh._id, 2)])
        assert self.wh.enforce_limit.call_count == 1

    @patch('allura.webhooks.log', autospec=True)
    @patch('allura.webhooks.send_webhook', autospec=True)
    def test_send_limit_reached(self, send_webhook, log):
        sender = RepoPushWebhookSender()
        sender.get_payload = Mock()
        self.wh.enforce_limit = Mock(return_value=False)
        with h.push_config(c, app=self.git):
            sender.send(dict(arg1=1, arg2=2))
        assert send_webhook.post.call_count == 0
        log.warning.assert_called_once_with(
            'Webhook fires too often: %s. Skipping', self.wh)

    @patch('allura.webhooks.send_webhook', autospec=True)
    def test_send_no_configured_webhooks(self, send_webhook):
        self.wh.delete()
        session(self.wh).flush(self.wh)
        sender = RepoPushWebhookSender()
        with h.push_config(c, app=self.git):
            sender.send(dict(arg1=1, arg2=2))
        assert send_webhook.post.call_count == 0

    def test_get_payload(self):
        sender = RepoPushWebhookSender()
        def _ci(x): return MagicMock(webhook_info={'id': str(x)}, parent_ids=['0'])
        with patch.object(self.git.repo, 'commit', new=_ci), h.push_config(c, app=self.git):
            result = sender.get_payload(commit_ids=['1', '2', '3'], ref='ref')
        expected_result = {
            'size': 3,
            'commits': [{'id': '1'}, {'id': '2'}, {'id': '3'}],
            'ref': 'ref',
            'after': '1',
            'before': '0',
            'repository': {
                'full_name': '/adobe/adobe-1/src/',
                'name': 'Git',
                'url': 'http://localhost/adobe/adobe-1/src/',
            },
        }
        assert result == expected_result

    def test_enforce_limit(self):
        def add_webhooks(suffix, n):
            for i in range(n):
                webhook = M.Webhook(
                    type='repo-push',
                    app_config_id=self.git.config._id,
                    hook_url=f'http://httpbin.org/{suffix}/{i}',
                    secret='secret')
                session(webhook).flush(webhook)

        sender = RepoPushWebhookSender()
        # default
        assert sender.enforce_limit(self.git) is True
        add_webhooks('one', 3)
        assert sender.enforce_limit(self.git) is False

        # config
        limit = json.dumps({'git': 5})
        with h.push_config(config, **{'webhook.repo_push.max_hooks': limit}):
            assert sender.enforce_limit(self.git) is True
            add_webhooks('two', 3)
            assert sender.enforce_limit(self.git) is False

    def test_before(self):
        sender = RepoPushWebhookSender()
        with patch.object(self.git.repo, 'commit', autospec=True) as _ci:
            assert sender._before(self.git.repo, ['3', '2', '1']) == ''
            _ci.return_value.parent_ids = ['0']
            assert sender._before(self.git.repo, ['3', '2', '1']) == '0'

    def test_after(self):
        sender = RepoPushWebhookSender()
        assert sender._after([]) == ''
        assert sender._after(['3', '2', '1']) == '3'

    def test_convert_id(self):
        sender = RepoPushWebhookSender()
        assert sender._convert_id('') == ''
        assert sender._convert_id('a433fa9') == 'a433fa9'
        assert sender._convert_id('a433fa9:13') == 'r13'


class TestModels(TestWebhookBase):
    def test_webhook_url(self):
        assert (self.wh.url() ==
                f'/adobe/adobe-1/admin/src/webhooks/repo-push/{self.wh._id}')

    def test_webhook_enforce_limit(self):
        self.wh.last_sent = None
        assert self.wh.enforce_limit() is True
        # default value
        self.wh.last_sent = dt.datetime.utcnow() - dt.timedelta(seconds=31)
        assert self.wh.enforce_limit() is True
        self.wh.last_sent = dt.datetime.utcnow() - dt.timedelta(seconds=15)
        assert self.wh.enforce_limit() is False
        # value from config
        with h.push_config(config, **{'webhook.repo_push.limit': 100}):
            self.wh.last_sent = dt.datetime.utcnow() - dt.timedelta(seconds=101)
            assert self.wh.enforce_limit() is True
            self.wh.last_sent = dt.datetime.utcnow() - dt.timedelta(seconds=35)
            assert self.wh.enforce_limit() is False

    @patch('allura.model.webhook.dt', autospec=True)
    def test_update_limit(self, dt_mock):
        _now = dt.datetime(2015, 2, 2, 13, 39)
        dt_mock.datetime.utcnow.return_value = _now
        assert self.wh.last_sent is None
        self.wh.update_limit()
        session(self.wh).expunge(self.wh)
        assert M.Webhook.query.get(_id=self.wh._id).last_sent == _now

    def test_json(self):
        expected = {
            '_id': str(self.wh._id),
            'url': 'http://localhost/rest/adobe/adobe-1/admin'
                   '/src/webhooks/repo-push/{}'.format(self.wh._id),
            'type': 'repo-push',
            'hook_url': 'http://httpbin.org/post',
            'mod_date': self.wh.mod_date,
        }
        assert self.wh.__json__() == expected


class TestWebhookRestController(TestRestApiBase):
    def setup_method(self, method):
        super().setup_method(method)
        self.patches = self.monkey_patch()
        for p in self.patches:
            p.start()
        self.setup_with_tools()
        self.project = M.Project.query.get(shortname=test_project_with_repo)
        self.git = self.project.app_instance('src')
        self.url = '/rest' + self.git.admin_url + 'webhooks'
        self.webhooks = []
        for i in range(3):
            webhook = M.Webhook(
                type='repo-push',
                app_config_id=self.git.config._id,
                hook_url=f'http://httpbin.org/post/{i}',
                secret=f'secret-{i}')
            session(webhook).flush(webhook)
            self.webhooks.append(webhook)

    def teardown_method(self, method):
        super().teardown_method(method)
        for p in self.patches:
            p.stop()

    @with_git
    def setup_with_tools(self):
        pass

    def monkey_patch(self):
        gen_secret = patch.object(
            WebhookController,
            'gen_secret',
            return_value='super-secret',
            autospec=True)
        # we don't need actual repo here, and this avoids test conflicts when
        # running in parallel
        repo_init = patch.object(M.Repository, 'init', autospec=True)
        return [gen_secret, repo_init]

    def test_webhooks_list(self):
        r = self.api_get(self.url)
        webhooks = [{
            '_id': str(wh._id),
            'url': 'http://localhost/rest/adobe/adobe-1/admin'
                   '/src/webhooks/repo-push/{}'.format(wh._id),
            'type': 'repo-push',
            'hook_url': f'http://httpbin.org/post/{n}',
            'mod_date': str(wh.mod_date),
        } for n, wh in enumerate(self.webhooks)]
        expected = {
            'webhooks': webhooks,
            'limits': {'repo-push': {'max': 3, 'used': 3}},
        }
        assert r.json == expected

    def test_webhook_GET_404(self):
        r = self.api_get(self.url + '/repo-push/invalid', status=404)

    def test_webhook_GET(self):
        webhook = self.webhooks[0]
        r = self.api_get(f'{self.url}/repo-push/{webhook._id}')
        expected = {
            '_id': str(webhook._id),
            'url': 'http://localhost/rest/adobe/adobe-1/admin'
                   '/src/webhooks/repo-push/{}'.format(webhook._id),
            'type': 'repo-push',
            'hook_url': 'http://httpbin.org/post/0',
            'mod_date': str(webhook.mod_date),
        }
        assert r.status_int == 200
        assert r.json == expected

    def test_create_validation(self):
        assert M.Webhook.query.find().count() == len(self.webhooks)
        r = self.api_get(self.url + '/repo-push', status=405)

        r = self.api_post(self.url + '/repo-push', status=400)
        expected = {
            'result': 'error',
            'error': {'url': 'Please enter a value'},
        }
        assert r.json == expected
        assert M.Webhook.query.find().count() == len(self.webhooks)

    def test_create(self):
        assert M.Webhook.query.find().count() == len(self.webhooks)
        data = {'url': 'http://hook.slack.com/abcd'}
        limit = json.dumps({'git': 10})
        with h.push_config(config, **{'webhook.repo_push.max_hooks': limit}):
            msg = 'add webhook repo-push {} {}'.format(
                data['url'], self.git.config.url())
            with td.audits(msg):
                r = self.api_post(self.url + '/repo-push', status=201, **data)
        webhook = M.Webhook.query.get(hook_url=data['url'])
        assert webhook.secret == 'super-secret'  # secret generated
        expected = {
            '_id': str(webhook._id),
            'url': 'http://localhost/rest/adobe/adobe-1/admin'
                   '/src/webhooks/repo-push/{}'.format(webhook._id),
            'type': 'repo-push',
            'hook_url': data['url'],
            'mod_date': str(webhook.mod_date),
        }
        assert r.json == expected
        assert M.Webhook.query.find().count() == len(self.webhooks) + 1

    def test_create_duplicates(self):
        assert M.Webhook.query.find().count() == len(self.webhooks)
        data = {'url': self.webhooks[0].hook_url}
        limit = json.dumps({'git': 10})
        with h.push_config(config, **{'webhook.repo_push.max_hooks': limit}):
            r = self.api_post(self.url + '/repo-push', status=400, **data)
        expected = {'result': 'error',
                    'error': '_the_form: "repo-push" webhook already '
                              'exists for Git http://httpbin.org/post/0'}
        assert r.json == expected
        assert M.Webhook.query.find().count() == len(self.webhooks)

    def test_create_limit_reached(self):
        assert M.Webhook.query.find().count() == len(self.webhooks)
        data = {'url': 'http://hook.slack.com/abcd'}
        r = self.api_post(self.url + '/repo-push', status=400, **data)
        expected = {
            'result': 'error',
            'limits': {'max': 3, 'used': 3},
            'error': 'You have exceeded the maximum number of webhooks '
                      'you are allowed to create for this project/app'}
        assert r.json == expected
        assert M.Webhook.query.find().count() == len(self.webhooks)

    def test_edit_validation(self):
        webhook = self.webhooks[0]
        url = f'{self.url}/repo-push/{webhook._id}'
        data = {'url': 'http://10.0.0.1/hook', 'secret': ''}
        r = self.api_post(url, status=400, **data)
        expected = {
            'result': 'error',
            'error': {
                'url': 'Invalid URL.'
            },
        }
        assert r.json == expected

    def test_edit(self):
        webhook = self.webhooks[0]
        url = f'{self.url}/repo-push/{webhook._id}'
        # change only url
        data = {'url': 'http://hook.slack.com/abcd'}
        msg = ('edit webhook repo-push\n'
               'http://httpbin.org/post/0 => http://hook.slack.com/abcd\n')
        with td.audits(msg):
            r = self.api_post(url, status=200, **data)
        webhook = M.Webhook.query.get(_id=webhook._id)
        assert webhook.hook_url == data['url']
        assert webhook.secret == 'secret-0'
        expected = {
            '_id': str(webhook._id),
            'url': 'http://localhost/rest/adobe/adobe-1/admin'
                   '/src/webhooks/repo-push/{}'.format(webhook._id),
            'type': 'repo-push',
            'hook_url': data['url'],
            'mod_date': str(webhook.mod_date),
        }
        assert r.json == expected

        # change only secret
        data = {'secret': 'new-secret'}
        msg = ('edit webhook repo-push\n'
               'http://hook.slack.com/abcd => http://hook.slack.com/abcd\n'
               'secret changed')
        with td.audits(msg):
            r = self.api_post(url, status=200, **data)
        webhook = M.Webhook.query.get(_id=webhook._id)
        assert webhook.hook_url == 'http://hook.slack.com/abcd'
        assert webhook.secret == 'new-secret'
        expected = {
            '_id': str(webhook._id),
            'url': 'http://localhost/rest/adobe/adobe-1/admin'
                   '/src/webhooks/repo-push/{}'.format(webhook._id),
            'type': 'repo-push',
            'hook_url': 'http://hook.slack.com/abcd',
            'mod_date': str(webhook.mod_date),
        }
        assert r.json == expected

    def test_edit_duplicates(self):
        webhook = self.webhooks[0]
        url = f'{self.url}/repo-push/{webhook._id}'
        data = {'url': 'http://httpbin.org/post/1'}
        r = self.api_post(url, status=400, **data)
        expected = {'result': 'error',
                    'error': '_the_form: "repo-push" webhook already '
                              'exists for Git http://httpbin.org/post/1'}
        assert r.json == expected

    def test_delete_validation(self):
        url = f'{self.url}/repo-push/invalid'
        self.api_delete(url, status=404)

    def test_delete(self):
        assert M.Webhook.query.find().count() == 3
        webhook = self.webhooks[0]
        url = f'{self.url}/repo-push/{webhook._id}'
        msg = 'delete webhook repo-push {} {}'.format(
            webhook.hook_url, self.git.config.url())
        with td.audits(msg):
            r = self.api_delete(url, status=200)
        assert r.json == {'result': 'ok'}
        assert M.Webhook.query.find().count() == 2
        assert M.Webhook.query.get(_id=webhook._id) is None

    def test_permissions(self):
        self.api_get(self.url, user='test-user', status=403)
        self.api_get(self.url, user='*anonymous', status=401)
        url = self.url + '/repo-push/'
        self.api_post(url, user='test-user', status=403)
        self.api_post(url, user='*anonymous', status=401)
        url = self.url + '/repo-push/' + str(self.webhooks[0]._id)
        self.api_get(url, user='test-user', status=403)
        self.api_get(url, user='*anonymous', status=401)
        self.api_post(url, user='test-user', status=403)
        self.api_post(url, user='*anonymous', status=401)
        self.api_delete(url, user='test-user', status=403)
        self.api_delete(url, user='*anonymous', status=401)
