From 4901cf78ad11ee22ace63f94da3bf8c700978964 Mon Sep 17 00:00:00 2001 From: Devaev Maxim Date: Sat, 4 May 2019 09:47:33 +0300 Subject: tests reorganized --- testenv/tests/apps/cleanup/__init__.py | 20 ++++ testenv/tests/apps/cleanup/test_main.py | 71 +++++++++++++ testenv/tests/apps/htpasswd/__init__.py | 20 ++++ testenv/tests/apps/htpasswd/test_main.py | 170 +++++++++++++++++++++++++++++++ testenv/tests/apps/kvmd/test_auth.py | 133 ++++++++++++++++++++++++ testenv/tests/apps/test_cleanup.py | 71 ------------- testenv/tests/apps/test_htpasswd.py | 170 ------------------------------- 7 files changed, 414 insertions(+), 241 deletions(-) create mode 100644 testenv/tests/apps/cleanup/__init__.py create mode 100644 testenv/tests/apps/cleanup/test_main.py create mode 100644 testenv/tests/apps/htpasswd/__init__.py create mode 100644 testenv/tests/apps/htpasswd/test_main.py create mode 100644 testenv/tests/apps/kvmd/test_auth.py delete mode 100644 testenv/tests/apps/test_cleanup.py delete mode 100644 testenv/tests/apps/test_htpasswd.py (limited to 'testenv/tests/apps') diff --git a/testenv/tests/apps/cleanup/__init__.py b/testenv/tests/apps/cleanup/__init__.py new file mode 100644 index 00000000..1e91f7fa --- /dev/null +++ b/testenv/tests/apps/cleanup/__init__.py @@ -0,0 +1,20 @@ +# ========================================================================== # +# # +# KVMD - The main Pi-KVM daemon. # +# # +# Copyright (C) 2018 Maxim Devaev # +# # +# This program 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. # +# # +# This program 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 this program. If not, see . # +# # +# ========================================================================== # diff --git a/testenv/tests/apps/cleanup/test_main.py b/testenv/tests/apps/cleanup/test_main.py new file mode 100644 index 00000000..3e2e4c72 --- /dev/null +++ b/testenv/tests/apps/cleanup/test_main.py @@ -0,0 +1,71 @@ +# ========================================================================== # +# # +# KVMD - The main Pi-KVM daemon. # +# # +# Copyright (C) 2018 Maxim Devaev # +# # +# This program 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. # +# # +# This program 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 this program. If not, see . # +# # +# ========================================================================== # + + +import os +import secrets +import multiprocessing +import multiprocessing.queues +import time + +import setproctitle + +from kvmd.apps.cleanup import main + + +# ===== +def test_ok(tmpdir) -> None: # type: ignore + queue: multiprocessing.queues.Queue = multiprocessing.Queue() + + ustreamer_tmp_path = os.path.abspath(str(tmpdir.join("ustr-" + secrets.token_hex(3)))) + os.symlink("/usr/bin/ustreamer", ustreamer_tmp_path) + + ustreamer_sock_path = os.path.abspath(str(tmpdir.join("ustreamer-fake.sock"))) + open(ustreamer_sock_path, "w").close() + kvmd_sock_path = os.path.abspath(str(tmpdir.join("kvmd-fake.sock"))) + open(kvmd_sock_path, "w").close() + + def ustreamer_fake() -> None: + setproctitle.setproctitle(os.path.basename(ustreamer_tmp_path)) + queue.put(True) + while True: + time.sleep(1) + + proc = multiprocessing.Process(target=ustreamer_fake, daemon=True) + proc.start() + assert queue.get(timeout=5) + + assert proc.is_alive() + main([ + "kvmd-cleanup", + "--set-options", + "kvmd/server/port=0", + "kvmd/server/unix=" + kvmd_sock_path, + "kvmd/streamer/port=0", + "kvmd/streamer/unix=" + ustreamer_sock_path, + "kvmd/streamer/cmd=" + ustreamer_tmp_path, + ]) + + assert not os.path.exists(ustreamer_sock_path) + assert not os.path.exists(kvmd_sock_path) + + assert not proc.is_alive() + proc.join() diff --git a/testenv/tests/apps/htpasswd/__init__.py b/testenv/tests/apps/htpasswd/__init__.py new file mode 100644 index 00000000..1e91f7fa --- /dev/null +++ b/testenv/tests/apps/htpasswd/__init__.py @@ -0,0 +1,20 @@ +# ========================================================================== # +# # +# KVMD - The main Pi-KVM daemon. # +# # +# Copyright (C) 2018 Maxim Devaev # +# # +# This program 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. # +# # +# This program 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 this program. If not, see . # +# # +# ========================================================================== # diff --git a/testenv/tests/apps/htpasswd/test_main.py b/testenv/tests/apps/htpasswd/test_main.py new file mode 100644 index 00000000..52a38640 --- /dev/null +++ b/testenv/tests/apps/htpasswd/test_main.py @@ -0,0 +1,170 @@ +# ========================================================================== # +# # +# KVMD - The main Pi-KVM daemon. # +# # +# Copyright (C) 2018 Maxim Devaev # +# # +# This program 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. # +# # +# This program 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 this program. If not, see . # +# # +# ========================================================================== # + + +import os +import hashlib +import tempfile +import builtins +import getpass + +from typing import List +from typing import Generator +from typing import Any + +import passlib.apache + +import pytest + +from kvmd.apps.htpasswd import main + + +# ===== +def _make_passwd(user: str) -> str: + return hashlib.md5(user.encode()).hexdigest() + + +@pytest.fixture(name="htpasswd", params=[[], ["admin"], ["admin", "user"]]) +def _htpasswd_fixture(request) -> Generator[passlib.apache.HtpasswdFile, None, None]: # type: ignore + (fd, path) = tempfile.mkstemp() + os.close(fd) + htpasswd = passlib.apache.HtpasswdFile(path) + for user in request.param: + htpasswd.set_password(user, _make_passwd(user)) + htpasswd.save() + yield htpasswd + os.remove(path) + + +def _run_htpasswd(cmd: List[str], htpasswd_path: str, internal_type: str="htpasswd") -> None: + cmd = ["kvmd-htpasswd", *cmd, "--set-options"] + if internal_type != "htpasswd": # By default + cmd.append("kvmd/auth/internal_type=" + internal_type) + if htpasswd_path: + cmd.append("kvmd/auth/internal/file=" + htpasswd_path) + main(cmd) + + +# ===== +def test_ok__list(htpasswd: passlib.apache.HtpasswdFile, capsys) -> None: # type: ignore + _run_htpasswd(["list"], htpasswd.path) + (out, err) = capsys.readouterr() + assert len(err) == 0 + assert sorted(filter(None, out.split("\n"))) == sorted(htpasswd.users()) == sorted(set(htpasswd.users())) + + +# ===== +def test_ok__set_change_stdin(htpasswd: passlib.apache.HtpasswdFile, mocker) -> None: # type: ignore + old_users = set(htpasswd.users()) + if old_users: + assert htpasswd.check_password("admin", _make_passwd("admin")) + + mocker.patch.object(builtins, "input", (lambda: " test ")) + _run_htpasswd(["set", "admin", "--read-stdin"], htpasswd.path) + + htpasswd.load(force=True) + assert htpasswd.check_password("admin", " test ") + assert old_users == set(htpasswd.users()) + + +def test_ok__set_add_stdin(htpasswd: passlib.apache.HtpasswdFile, mocker) -> None: # type: ignore + old_users = set(htpasswd.users()) + if old_users: + mocker.patch.object(builtins, "input", (lambda: " test ")) + _run_htpasswd(["set", "new", "--read-stdin"], htpasswd.path) + + htpasswd.load(force=True) + assert htpasswd.check_password("new", " test ") + assert old_users.union(["new"]) == set(htpasswd.users()) + + +# ===== +def test_ok__set_change_getpass(htpasswd: passlib.apache.HtpasswdFile, mocker) -> None: # type: ignore + old_users = set(htpasswd.users()) + if old_users: + assert htpasswd.check_password("admin", _make_passwd("admin")) + + mocker.patch.object(getpass, "getpass", (lambda *_, **__: " test ")) + _run_htpasswd(["set", "admin"], htpasswd.path) + + htpasswd.load(force=True) + assert htpasswd.check_password("admin", " test ") + assert old_users == set(htpasswd.users()) + + +def test_fail__set_change_getpass(htpasswd: passlib.apache.HtpasswdFile, mocker) -> None: # type: ignore + old_users = set(htpasswd.users()) + if old_users: + assert htpasswd.check_password("admin", _make_passwd("admin")) + + count = 0 + + def fake_getpass(*_: Any, **__: Any) -> str: + nonlocal count + assert count <= 1 + if count == 0: + passwd = " test " + else: + passwd = "test " + count += 1 + return passwd + + mocker.patch.object(getpass, "getpass", fake_getpass) + with pytest.raises(SystemExit, match="Sorry, passwords do not match"): + _run_htpasswd(["set", "admin"], htpasswd.path) + assert count == 2 + + htpasswd.load(force=True) + assert htpasswd.check_password("admin", _make_passwd("admin")) + assert old_users == set(htpasswd.users()) + + +# ===== +def test_ok__del(htpasswd: passlib.apache.HtpasswdFile) -> None: + old_users = set(htpasswd.users()) + + if old_users: + assert htpasswd.check_password("admin", _make_passwd("admin")) + + _run_htpasswd(["del", "admin"], htpasswd.path) + + htpasswd.load(force=True) + assert not htpasswd.check_password("admin", _make_passwd("admin")) + assert old_users.difference(["admin"]) == set(htpasswd.users()) + + +# ===== +def test_fail__not_htpasswd() -> None: + with pytest.raises(SystemExit, match="Error: KVMD internal auth not using 'htpasswd'"): + _run_htpasswd(["list"], "", internal_type="http") + + +def test_fail__unknown_plugin() -> None: + with pytest.raises(SystemExit, match="Config error: Unknown plugin 'auth/foobar'"): + _run_htpasswd(["list"], "", internal_type="foobar") + + +def test_fail__invalid_passwd(mocker, tmpdir) -> None: # type: ignore + path = os.path.abspath(str(tmpdir.join("htpasswd"))) + open(path, "w").close() + mocker.patch.object(builtins, "input", (lambda: "\n")) + with pytest.raises(SystemExit, match="The argument is not a valid passwd characters"): + _run_htpasswd(["set", "admin", "--read-stdin"], path) diff --git a/testenv/tests/apps/kvmd/test_auth.py b/testenv/tests/apps/kvmd/test_auth.py new file mode 100644 index 00000000..be6b6455 --- /dev/null +++ b/testenv/tests/apps/kvmd/test_auth.py @@ -0,0 +1,133 @@ +# ========================================================================== # +# # +# KVMD - The main Pi-KVM daemon. # +# # +# Copyright (C) 2018 Maxim Devaev # +# # +# This program 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. # +# # +# This program 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 this program. If not, see . # +# # +# ========================================================================== # + + +import os +import contextlib + +from typing import List +from typing import Dict +from typing import AsyncGenerator +from typing import Optional + +import passlib.apache + +import pytest + +from kvmd.yamlconf import make_config + +from kvmd.apps.kvmd.auth import AuthManager + +from kvmd.plugins.auth import get_auth_service_class + + +# ===== +def _make_service_kwargs(path: str) -> Dict: + cls = get_auth_service_class("htpasswd") + scheme = cls.get_plugin_options() + return make_config({"file": path}, scheme)._unpack() # pylint: disable=protected-access + + +@contextlib.asynccontextmanager +async def _get_configured_manager( + internal_path: str, + external_path: str="", + internal_users: Optional[List[str]]=None, +) -> AsyncGenerator[AuthManager, None]: + + manager = AuthManager( + internal_type="htpasswd", + internal_kwargs=_make_service_kwargs(internal_path), + external_type=("htpasswd" if external_path else ""), + external_kwargs=(_make_service_kwargs(external_path) if external_path else {}), + internal_users=(internal_users or []), + ) + + try: + yield manager + finally: + await manager.cleanup() + + +# ===== +@pytest.mark.asyncio +async def test_ok__internal(tmpdir) -> None: # type: ignore + path = os.path.abspath(str(tmpdir.join("htpasswd"))) + + htpasswd = passlib.apache.HtpasswdFile(path, new=True) + htpasswd.set_password("admin", "pass") + htpasswd.save() + + async with _get_configured_manager(path) as manager: + assert manager.check("xxx") is None + manager.logout("xxx") + + assert (await manager.login("user", "foo")) is None + assert (await manager.login("admin", "foo")) is None + assert (await manager.login("user", "pass")) is None + + token = await manager.login("admin", "pass") + assert isinstance(token, str) + assert len(token) == 64 + + again = await manager.login("admin", "pass") + assert token == again + + assert manager.check(token) == "admin" + manager.logout(token) + assert manager.check(token) is None + + again = await manager.login("admin", "pass") + assert token != again + + +@pytest.mark.asyncio +async def test_ok__external(tmpdir) -> None: # type: ignore + path1 = os.path.abspath(str(tmpdir.join("htpasswd1"))) + path2 = os.path.abspath(str(tmpdir.join("htpasswd2"))) + + htpasswd1 = passlib.apache.HtpasswdFile(path1, new=True) + htpasswd1.set_password("admin", "pass1") + htpasswd1.set_password("local", "foobar") + htpasswd1.save() + + htpasswd2 = passlib.apache.HtpasswdFile(path2, new=True) + htpasswd2.set_password("admin", "pass2") + htpasswd2.set_password("user", "foobar") + htpasswd2.save() + + async with _get_configured_manager(path1, path2, ["admin"]) as manager: + assert (await manager.login("local", "foobar")) is None + assert (await manager.login("admin", "pass2")) is None + + token = await manager.login("admin", "pass1") + assert token is not None + + assert manager.check(token) == "admin" + manager.logout(token) + assert manager.check(token) is None + + token = await manager.login("user", "foobar") + assert token is not None + + assert manager.check(token) == "user" + manager.logout(token) + assert manager.check(token) is None diff --git a/testenv/tests/apps/test_cleanup.py b/testenv/tests/apps/test_cleanup.py deleted file mode 100644 index 3e2e4c72..00000000 --- a/testenv/tests/apps/test_cleanup.py +++ /dev/null @@ -1,71 +0,0 @@ -# ========================================================================== # -# # -# KVMD - The main Pi-KVM daemon. # -# # -# Copyright (C) 2018 Maxim Devaev # -# # -# This program 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. # -# # -# This program 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 this program. If not, see . # -# # -# ========================================================================== # - - -import os -import secrets -import multiprocessing -import multiprocessing.queues -import time - -import setproctitle - -from kvmd.apps.cleanup import main - - -# ===== -def test_ok(tmpdir) -> None: # type: ignore - queue: multiprocessing.queues.Queue = multiprocessing.Queue() - - ustreamer_tmp_path = os.path.abspath(str(tmpdir.join("ustr-" + secrets.token_hex(3)))) - os.symlink("/usr/bin/ustreamer", ustreamer_tmp_path) - - ustreamer_sock_path = os.path.abspath(str(tmpdir.join("ustreamer-fake.sock"))) - open(ustreamer_sock_path, "w").close() - kvmd_sock_path = os.path.abspath(str(tmpdir.join("kvmd-fake.sock"))) - open(kvmd_sock_path, "w").close() - - def ustreamer_fake() -> None: - setproctitle.setproctitle(os.path.basename(ustreamer_tmp_path)) - queue.put(True) - while True: - time.sleep(1) - - proc = multiprocessing.Process(target=ustreamer_fake, daemon=True) - proc.start() - assert queue.get(timeout=5) - - assert proc.is_alive() - main([ - "kvmd-cleanup", - "--set-options", - "kvmd/server/port=0", - "kvmd/server/unix=" + kvmd_sock_path, - "kvmd/streamer/port=0", - "kvmd/streamer/unix=" + ustreamer_sock_path, - "kvmd/streamer/cmd=" + ustreamer_tmp_path, - ]) - - assert not os.path.exists(ustreamer_sock_path) - assert not os.path.exists(kvmd_sock_path) - - assert not proc.is_alive() - proc.join() diff --git a/testenv/tests/apps/test_htpasswd.py b/testenv/tests/apps/test_htpasswd.py deleted file mode 100644 index 52a38640..00000000 --- a/testenv/tests/apps/test_htpasswd.py +++ /dev/null @@ -1,170 +0,0 @@ -# ========================================================================== # -# # -# KVMD - The main Pi-KVM daemon. # -# # -# Copyright (C) 2018 Maxim Devaev # -# # -# This program 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. # -# # -# This program 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 this program. If not, see . # -# # -# ========================================================================== # - - -import os -import hashlib -import tempfile -import builtins -import getpass - -from typing import List -from typing import Generator -from typing import Any - -import passlib.apache - -import pytest - -from kvmd.apps.htpasswd import main - - -# ===== -def _make_passwd(user: str) -> str: - return hashlib.md5(user.encode()).hexdigest() - - -@pytest.fixture(name="htpasswd", params=[[], ["admin"], ["admin", "user"]]) -def _htpasswd_fixture(request) -> Generator[passlib.apache.HtpasswdFile, None, None]: # type: ignore - (fd, path) = tempfile.mkstemp() - os.close(fd) - htpasswd = passlib.apache.HtpasswdFile(path) - for user in request.param: - htpasswd.set_password(user, _make_passwd(user)) - htpasswd.save() - yield htpasswd - os.remove(path) - - -def _run_htpasswd(cmd: List[str], htpasswd_path: str, internal_type: str="htpasswd") -> None: - cmd = ["kvmd-htpasswd", *cmd, "--set-options"] - if internal_type != "htpasswd": # By default - cmd.append("kvmd/auth/internal_type=" + internal_type) - if htpasswd_path: - cmd.append("kvmd/auth/internal/file=" + htpasswd_path) - main(cmd) - - -# ===== -def test_ok__list(htpasswd: passlib.apache.HtpasswdFile, capsys) -> None: # type: ignore - _run_htpasswd(["list"], htpasswd.path) - (out, err) = capsys.readouterr() - assert len(err) == 0 - assert sorted(filter(None, out.split("\n"))) == sorted(htpasswd.users()) == sorted(set(htpasswd.users())) - - -# ===== -def test_ok__set_change_stdin(htpasswd: passlib.apache.HtpasswdFile, mocker) -> None: # type: ignore - old_users = set(htpasswd.users()) - if old_users: - assert htpasswd.check_password("admin", _make_passwd("admin")) - - mocker.patch.object(builtins, "input", (lambda: " test ")) - _run_htpasswd(["set", "admin", "--read-stdin"], htpasswd.path) - - htpasswd.load(force=True) - assert htpasswd.check_password("admin", " test ") - assert old_users == set(htpasswd.users()) - - -def test_ok__set_add_stdin(htpasswd: passlib.apache.HtpasswdFile, mocker) -> None: # type: ignore - old_users = set(htpasswd.users()) - if old_users: - mocker.patch.object(builtins, "input", (lambda: " test ")) - _run_htpasswd(["set", "new", "--read-stdin"], htpasswd.path) - - htpasswd.load(force=True) - assert htpasswd.check_password("new", " test ") - assert old_users.union(["new"]) == set(htpasswd.users()) - - -# ===== -def test_ok__set_change_getpass(htpasswd: passlib.apache.HtpasswdFile, mocker) -> None: # type: ignore - old_users = set(htpasswd.users()) - if old_users: - assert htpasswd.check_password("admin", _make_passwd("admin")) - - mocker.patch.object(getpass, "getpass", (lambda *_, **__: " test ")) - _run_htpasswd(["set", "admin"], htpasswd.path) - - htpasswd.load(force=True) - assert htpasswd.check_password("admin", " test ") - assert old_users == set(htpasswd.users()) - - -def test_fail__set_change_getpass(htpasswd: passlib.apache.HtpasswdFile, mocker) -> None: # type: ignore - old_users = set(htpasswd.users()) - if old_users: - assert htpasswd.check_password("admin", _make_passwd("admin")) - - count = 0 - - def fake_getpass(*_: Any, **__: Any) -> str: - nonlocal count - assert count <= 1 - if count == 0: - passwd = " test " - else: - passwd = "test " - count += 1 - return passwd - - mocker.patch.object(getpass, "getpass", fake_getpass) - with pytest.raises(SystemExit, match="Sorry, passwords do not match"): - _run_htpasswd(["set", "admin"], htpasswd.path) - assert count == 2 - - htpasswd.load(force=True) - assert htpasswd.check_password("admin", _make_passwd("admin")) - assert old_users == set(htpasswd.users()) - - -# ===== -def test_ok__del(htpasswd: passlib.apache.HtpasswdFile) -> None: - old_users = set(htpasswd.users()) - - if old_users: - assert htpasswd.check_password("admin", _make_passwd("admin")) - - _run_htpasswd(["del", "admin"], htpasswd.path) - - htpasswd.load(force=True) - assert not htpasswd.check_password("admin", _make_passwd("admin")) - assert old_users.difference(["admin"]) == set(htpasswd.users()) - - -# ===== -def test_fail__not_htpasswd() -> None: - with pytest.raises(SystemExit, match="Error: KVMD internal auth not using 'htpasswd'"): - _run_htpasswd(["list"], "", internal_type="http") - - -def test_fail__unknown_plugin() -> None: - with pytest.raises(SystemExit, match="Config error: Unknown plugin 'auth/foobar'"): - _run_htpasswd(["list"], "", internal_type="foobar") - - -def test_fail__invalid_passwd(mocker, tmpdir) -> None: # type: ignore - path = os.path.abspath(str(tmpdir.join("htpasswd"))) - open(path, "w").close() - mocker.patch.object(builtins, "input", (lambda: "\n")) - with pytest.raises(SystemExit, match="The argument is not a valid passwd characters"): - _run_htpasswd(["set", "admin", "--read-stdin"], path) -- cgit v1.2.3