qutebrowser/tests/unit/misc/userscripts/test_qute_lastpass.py

347 lines
13 KiB
Python

# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2020 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
#
# This file is part of qutebrowser.
#
# qutebrowser is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# qutebrowser is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
"""Tests for misc.userscripts.qute-lastpass."""
import json
from types import SimpleNamespace
from unittest.mock import ANY, call
import attr
import pytest
from helpers import utils
qute_lastpass = utils.import_userscript('qute-lastpass')
default_lpass_match = [
{
"id": "12345",
"name": "www.example.com",
"username": "fake@fake.com",
"password": "foobar",
"url": "https://www.example.com",
}
]
@attr.s
class FakeOutput:
stdout = attr.ib(default='', converter=str.encode)
stderr = attr.ib(default='', converter=str.encode)
@pytest.fixture
def subprocess_mock(mocker):
return mocker.patch('subprocess.run')
@pytest.fixture
def qutecommand_mock(mocker):
return mocker.patch.object(qute_lastpass, 'qute_command')
@pytest.fixture
def stderr_mock(mocker):
return mocker.patch.object(qute_lastpass, 'stderr')
# Default arguments passed to qute-lastpass
@pytest.fixture
def arguments_mock():
arguments = SimpleNamespace()
arguments.url = ''
arguments.dmenu_invocation = 'rofi -dmenu'
arguments.insert_mode = True
arguments.io_encoding = 'UTF-8'
arguments.merge_candidates = False
arguments.password_only = False
arguments.username_only = False
return arguments
class TestQuteLastPassComponents:
"""Test qute-lastpass components."""
def test_fake_key_raw(self, qutecommand_mock):
"""Test if fake_key_raw properly escapes characters."""
qute_lastpass.fake_key_raw('john.doe@example.com ')
qutecommand_mock.assert_called_once_with(
'fake-key \\j\\o\\h\\n\\.\\d\\o\\e\\@\\e\\x\\a\\m\\p\\l\\e\\.\\c\\o\\m" "'
)
def test_dmenu(self, subprocess_mock):
"""Test if dmenu command receives properly formatted lpass entries."""
entries = [
"1234 | example.com | https://www.example.com | john.doe@example.com",
"2345 | example2.com | https://www.example2.com | jane.doe@example.com",
]
subprocess_mock.return_value = FakeOutput(stdout=entries[1])
selected = qute_lastpass.dmenu(entries, 'rofi -dmenu', 'UTF-8')
subprocess_mock.assert_called_once_with(
['rofi', '-dmenu'],
input='\n'.join(entries).encode(),
stdout=ANY)
assert selected == entries[1]
def test_pass_subprocess_args(self, subprocess_mock):
"""Test if pass_ calls subprocess with correct arguments."""
subprocess_mock.return_value = FakeOutput(stdout='[{}]')
qute_lastpass.pass_('example.com', 'utf-8')
subprocess_mock.assert_called_once_with(
['lpass', 'show', '-x', '-j', '-G', '\\bexample\\.com'],
stdout=ANY, stderr=ANY)
def test_pass_returns_candidates(self, subprocess_mock):
"""Test if pass_ returns expected lpass site entry."""
subprocess_mock.return_value = FakeOutput(
stdout=json.dumps(default_lpass_match))
response = qute_lastpass.pass_('www.example.com', 'utf-8')
assert response[1] == ''
candidates = response[0]
assert len(candidates) == 1
assert candidates[0] == default_lpass_match[0]
def test_pass_no_accounts(self, subprocess_mock):
"""Test if pass_ handles no accounts as an empty lpass result."""
error_message = 'Error: Could not find specified account(s).'
subprocess_mock.return_value = FakeOutput(stderr=error_message)
response = qute_lastpass.pass_('www.example.com', 'utf-8')
assert response[0] == []
assert response[1] == ''
def test_pass_returns_error(self, subprocess_mock):
"""Test if pass_ returns error from lpass."""
# pylint: disable=line-too-long
error_message = 'Error: Could not find decryption key. Perhaps you need to login with `lpass login`.'
subprocess_mock.return_value = FakeOutput(stderr=error_message)
response = qute_lastpass.pass_('www.example.com', 'utf-8')
assert response[0] == []
assert response[1] == error_message
class TestQuteLastPassMain:
"""Test qute-lastpass main."""
def test_main_happy_path(self, subprocess_mock, arguments_mock,
qutecommand_mock):
"""Test sending username/password to qutebrowser on *single* match."""
subprocess_mock.return_value = FakeOutput(
stdout=json.dumps(default_lpass_match))
arguments_mock.url = default_lpass_match[0]['url']
exit_code = qute_lastpass.main(arguments_mock)
assert exit_code == qute_lastpass.ExitCodes.SUCCESS
qutecommand_mock.assert_has_calls([
call('fake-key \\f\\a\\k\\e\\@\\f\\a\\k\\e\\.\\c\\o\\m'),
call('fake-key <Tab>'),
call('fake-key \\f\\o\\o\\b\\a\\r'),
call('enter-mode insert')
])
def test_main_no_candidates(self, subprocess_mock, arguments_mock,
stderr_mock,
qutecommand_mock):
"""Test correct exit code and message returned on no entries."""
error_message = 'Error: Could not find specified account(s).'
subprocess_mock.return_value = FakeOutput(stderr=error_message)
arguments_mock.url = default_lpass_match[0]['url']
exit_code = qute_lastpass.main(arguments_mock)
assert exit_code == qute_lastpass.ExitCodes.NO_PASS_CANDIDATES
stderr_mock.assert_called_with(
"No pass candidates for URL 'https://www.example.com' found!")
qutecommand_mock.assert_not_called()
def test_main_lpass_failure(self, subprocess_mock, arguments_mock,
stderr_mock,
qutecommand_mock):
"""Test correct exit code and message on lpass failure."""
# pylint: disable=line-too-long
error_message = 'Error: Could not find decryption key. Perhaps you need to login with `lpass login`.'
subprocess_mock.return_value = FakeOutput(stderr=error_message)
arguments_mock.url = default_lpass_match[0]['url']
exit_code = qute_lastpass.main(arguments_mock)
assert exit_code == qute_lastpass.ExitCodes.FAILURE
# pylint: disable=line-too-long
stderr_mock.assert_called_with(
"LastPass CLI returned for www.example.com - Error: Could not find decryption key. Perhaps you need to login with `lpass login`.")
qutecommand_mock.assert_not_called()
def test_main_username_only_flag(self, subprocess_mock, arguments_mock,
qutecommand_mock):
"""Test if --username-only flag sends username only."""
subprocess_mock.return_value = FakeOutput(
stdout=json.dumps(default_lpass_match))
arguments_mock.url = default_lpass_match[0]['url']
arguments_mock.username_only = True
qute_lastpass.main(arguments_mock)
qutecommand_mock.assert_has_calls([
call('fake-key \\f\\a\\k\\e\\@\\f\\a\\k\\e\\.\\c\\o\\m'),
call('enter-mode insert')
])
def test_main_password_only_flag(self, subprocess_mock, arguments_mock,
qutecommand_mock):
"""Test if --password-only flag sends password only."""
subprocess_mock.return_value = FakeOutput(
stdout=json.dumps(default_lpass_match))
arguments_mock.url = default_lpass_match[0]['url']
arguments_mock.password_only = True
qute_lastpass.main(arguments_mock)
qutecommand_mock.assert_has_calls([
call('fake-key \\f\\o\\o\\b\\a\\r'),
call('enter-mode insert')
])
def test_main_multiple_candidates(self, subprocess_mock, arguments_mock,
qutecommand_mock):
"""Test dmenu-invocation when lpass returns multiple candidates."""
multiple_matches = default_lpass_match.copy()
multiple_matches.append(
{
"id": "23456",
"name": "Sites/www.example.com",
"username": "john.doe@fake.com",
"password": "barfoo",
"url": "https://www.example.com",
}
)
lpass_response = FakeOutput(stdout=json.dumps(multiple_matches))
dmenu_response = FakeOutput(
stdout='23456 | Sites/www.example.com | https://www.example.com | john.doe@fake.com')
subprocess_mock.side_effect = [lpass_response, dmenu_response]
arguments_mock.url = multiple_matches[0]['url']
exit_code = qute_lastpass.main(arguments_mock)
assert exit_code == qute_lastpass.ExitCodes.SUCCESS
subprocess_mock.assert_has_calls([
call(['lpass', 'show', '-x', '-j', '-G', '\\bwww\\.example\\.com'],
stdout=ANY, stderr=ANY),
call(['rofi', '-dmenu'],
input=b'12345 | www.example.com | https://www.example.com | fake@fake.com\n23456 | Sites/www.example.com | https://www.example.com | john.doe@fake.com',
stdout=ANY)
])
qutecommand_mock.assert_has_calls([
call(
'fake-key \\j\\o\\h\\n\\.\\d\\o\\e\\@\\f\\a\\k\\e\\.\\c\\o\\m'),
call('fake-key <Tab>'),
call('fake-key \\b\\a\\r\\f\\o\\o'),
call('enter-mode insert')
])
def test_main_merge_candidates(self, subprocess_mock, arguments_mock,
qutecommand_mock):
"""Test merge of multiple responses from lpass."""
fqdn_matches = default_lpass_match.copy()
fqdn_matches.append(
{
"id": "23456",
"name": "Sites/www.example.com",
"username": "john.doe@fake.com",
"password": "barfoo",
"url": "https://www.example.com",
}
)
domain_matches = [
{
"id": "345",
"name": "example.com",
"username": "joe.doe@fake.com",
"password": "barfoo1",
"url": "https://example.com",
},
{
"id": "456",
"name": "Sites/example.com",
"username": "jane.doe@fake.com",
"password": "foofoo2",
"url": "http://example.com",
}
]
fqdn_response = FakeOutput(stdout=json.dumps(fqdn_matches))
domain_response = FakeOutput(stdout=json.dumps(domain_matches))
no_response = FakeOutput(
stderr='Error: Could not find specified account(s).')
dmenu_response = FakeOutput(
stdout='23456 | Sites/www.example.com | https://www.example.com | john.doe@fake.com')
# lpass command will return results for search against
# www.example.com, example.com, but not wwwexample.com and its ipv4
subprocess_mock.side_effect = [fqdn_response, domain_response,
no_response, no_response,
dmenu_response]
arguments_mock.url = fqdn_matches[0]['url']
arguments_mock.merge_candidates = True
exit_code = qute_lastpass.main(arguments_mock)
assert exit_code == qute_lastpass.ExitCodes.SUCCESS
subprocess_mock.assert_has_calls([
call(['lpass', 'show', '-x', '-j', '-G', '\\bwww\\.example\\.com'],
stdout=ANY, stderr=ANY),
call(['lpass', 'show', '-x', '-j', '-G', '\\bexample\\.com'],
stdout=ANY, stderr=ANY),
call(['lpass', 'show', '-x', '-j', '-G', '\\bwwwexample'],
stdout=ANY, stderr=ANY),
call(['lpass', 'show', '-x', '-j', '-G', '\\bexample'],
stdout=ANY, stderr=ANY),
call(['rofi', '-dmenu'],
input=b'12345 | www.example.com | https://www.example.com | fake@fake.com\n23456 | Sites/www.example.com | https://www.example.com | john.doe@fake.com\n345 | example.com | https://example.com | joe.doe@fake.com\n456 | Sites/example.com | http://example.com | jane.doe@fake.com',
stdout=ANY)
])
qutecommand_mock.assert_has_calls([
call(
'fake-key \\j\\o\\h\\n\\.\\d\\o\\e\\@\\f\\a\\k\\e\\.\\c\\o\\m'),
call('fake-key <Tab>'),
call('fake-key \\b\\a\\r\\f\\o\\o'),
call('enter-mode insert')
])