Merge ~przemeklal/charm-local-users:add-tests into charm-local-users:main

Proposed by Przemyslaw Lal
Status: Merged
Approved by: James Troup
Approved revision: c03ad89ec2e0abd0290e3fd229f11e0fa7082a44
Merged at revision: 3fcfb4b15c5a94f0eb7dd29c899ae5e2dfb6a57f
Proposed branch: ~przemeklal/charm-local-users:add-tests
Merge into: charm-local-users:main
Diff against target: 316 lines (+242/-3)
6 files modified
.gitignore (+1/-0)
run_tests (+1/-0)
src/lib/local_users.py (+6/-3)
tests/__init__.py (+7/-0)
tests/test_charm.py (+73/-0)
tests/test_local_users.py (+154/-0)
Reviewer Review Type Date Requested Status
BootStack Reviewers Pending
BootStack Reviewers Pending
Review via email: mp+412759@code.launchpad.net

Commit message

Add tests and fix minor issues detected by tests

Signed-off-by: Przemysław Lal <email address hidden>

To post a comment you must log in.
Revision history for this message
Przemyslaw Lal (przemeklal) wrote (last edit ):

Tests are passing, total coverage is 74%:

Name Stmts Miss Branch BrPart Cover Missing
--------------------------------------------------------------------
src/__init__.py 0 0 0 0 100%
src/charm.py 77 21 34 7 69% 55, 68->72, 77->90, 79-84, 111, 113-120, 127-129, 139-145, 149
src/lib/__init__.py 0 0 0 0 100%
src/lib/local_users.py 113 22 32 2 78% 35-43, 52-61, 66-67, 82-84, 101-102, 141->exit
--------------------------------------------------------------------
TOTAL 190 43 66 9 74%

Revision history for this message
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote :

This merge proposal is being monitored by mergebot. Change the status to Approved to merge.

Revision history for this message
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote :

Change successfully merged at revision 3fcfb4b15c5a94f0eb7dd29c899ae5e2dfb6a57f

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/.gitignore b/.gitignore
2index 2c3f0e5..f55930e 100644
3--- a/.gitignore
4+++ b/.gitignore
5@@ -3,5 +3,6 @@ build/
6 *.charm
7
8 .coverage
9+coverage.xml
10 __pycache__/
11 *.py[cod]
12diff --git a/run_tests b/run_tests
13index 78c5a62..9f708b5 100755
14--- a/run_tests
15+++ b/run_tests
16@@ -25,4 +25,5 @@ fi
17
18 flake8
19 coverage run --branch --source=src -m unittest -v "$@"
20+coverage xml -o coverage.xml
21 coverage report -m
22diff --git a/src/lib/local_users.py b/src/lib/local_users.py
23index 7a32124..7267967 100755
24--- a/src/lib/local_users.py
25+++ b/src/lib/local_users.py
26@@ -27,6 +27,8 @@ log = logging.getLogger(__name__)
27
28 User = namedtuple("User", ["name", "gecos", "ssh_key"])
29
30+HOME_DIR_PATH = "/home"
31+
32
33 def add_user(username, shell="/bin/bash", home_dir=None, gecos=None):
34 """Add a user to the system."""
35@@ -102,15 +104,16 @@ def remove_group(group_name):
36
37 def rename_group(old_name, new_name):
38 """Rename the `old_name` group to `new_name` using `groupmod` command."""
39- cmd = ["groupmod", "-n", new_name, old_name]
40- subprocess.check_call(cmd)
41+ if old_name != new_name:
42+ cmd = ["groupmod", "-n", new_name, old_name]
43+ subprocess.check_call(cmd)
44
45
46 def set_ssh_authorized_key(user):
47 """Idempotently set up the SSH public key in `authorized_keys`."""
48 comment = "# charm-local-users"
49 authorized_key = " ".join([user.ssh_key, comment])
50- ssh_path = os.path.join("/home", user.name, ".ssh")
51+ ssh_path = os.path.join(HOME_DIR_PATH, user.name, ".ssh")
52 authorized_keys_path = os.path.join(ssh_path, "authorized_keys")
53 if not os.path.exists(ssh_path):
54 os.makedirs(ssh_path, mode=0o700)
55diff --git a/tests/__init__.py b/tests/__init__.py
56index e69de29..bb15096 100644
57--- a/tests/__init__.py
58+++ b/tests/__init__.py
59@@ -0,0 +1,7 @@
60+import sys
61+from unittest import mock
62+
63+# mock shutil for all tests
64+shutil = mock.MagicMock()
65+shutil.chown = mock.MagicMock()
66+sys.modules['shutil'] = shutil
67diff --git a/tests/test_charm.py b/tests/test_charm.py
68index f0bbaa2..ce70df8 100644
69--- a/tests/test_charm.py
70+++ b/tests/test_charm.py
71@@ -13,8 +13,10 @@
72 # limitations under the License.
73
74 import unittest
75+from unittest.mock import patch
76
77 from charm import CharmLocalUsersCharm
78+from ops.model import ActiveStatus, BlockedStatus
79 from ops.testing import Harness
80
81
82@@ -23,3 +25,74 @@ class TestCharm(unittest.TestCase):
83 self.harness = Harness(CharmLocalUsersCharm)
84 self.addCleanup(self.harness.cleanup)
85 self.harness.begin()
86+
87+ @patch("os.makedirs")
88+ @patch("charmhelpers.core.host.group_exists")
89+ @patch("charm.rename_group")
90+ @patch("charm.get_group_users")
91+ @patch("charmhelpers.core.host.add_group")
92+ @patch("charm.configure_user")
93+ def test_config_changed(
94+ self,
95+ mock_conf_user,
96+ mock_add_group,
97+ mock_get_gr_users,
98+ mock_rename,
99+ mock_exists,
100+ _,
101+ ):
102+ # group doesn't exist yet
103+ mock_exists.return_value = False
104+
105+ # correct configuration
106+ self.harness.update_config(
107+ {"group": "testgroup", "users": "test1;Test 1;key1\ntest2;Test 2;key2"}
108+ )
109+ # a new group must be created
110+ mock_add_group.assert_called_once_with("testgroup")
111+ # first execution, no rename expected
112+ mock_rename.assert_not_called()
113+ mock_get_gr_users.assert_called_once()
114+ # 2 users to configure
115+ self.assertEqual(mock_conf_user.call_count, 2)
116+ # everything went well
117+ self.assertIsInstance(self.harness.model.unit.status, ActiveStatus)
118+
119+ @patch("os.makedirs")
120+ @patch("charmhelpers.core.host.group_exists")
121+ @patch("charm.rename_group")
122+ @patch("charm.get_group_users")
123+ @patch("charmhelpers.core.host.add_group")
124+ @patch("charm.configure_user")
125+ def test_config_changed_invalid_userlist(
126+ self,
127+ mock_conf_user,
128+ mock_add_group,
129+ mock_get_gr_users,
130+ mock_rename,
131+ mock_exists,
132+ _,
133+ ):
134+ # group doesn't exist yet
135+ mock_exists.return_value = False
136+
137+ # empty users list
138+ self.harness.update_config(
139+ {"group": "testgroup", "users": ";User with no name;\n"}
140+ )
141+
142+ # a new group must be created
143+ mock_add_group.assert_called_once_with("testgroup")
144+ # first execution, no rename expected
145+ mock_rename.assert_not_called()
146+ # we shouldn't be comapring with existing users if the config is invalid
147+ mock_get_gr_users.assert_not_called()
148+ # no users to configure
149+ mock_conf_user.assert_not_called()
150+ # we should enter blocked state
151+ self.assertIsInstance(self.harness.model.unit.status, BlockedStatus)
152+
153+ @patch("os.makedirs")
154+ def test_empty_group_config(self, _):
155+ self.harness.update_config({"group": "", "users": "test;;"})
156+ self.assertIsInstance(self.harness.model.unit.status, BlockedStatus)
157diff --git a/tests/test_local_users.py b/tests/test_local_users.py
158new file mode 100644
159index 0000000..b53720f
160--- /dev/null
161+++ b/tests/test_local_users.py
162@@ -0,0 +1,154 @@
163+# Copyright 2021 Canonical
164+#
165+# Licensed under the Apache License, Version 2.0 (the "License");
166+# you may not use this file except in compliance with the License.
167+# You may obtain a copy of the License at
168+#
169+# http://www.apache.org/licenses/LICENSE-2.0
170+#
171+# Unless required by applicable law or agreed to in writing, software
172+# distributed under the License is distributed on an "AS IS" BASIS,
173+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
174+# See the License for the specific language governing permissions and
175+# limitations under the License.
176+
177+import os
178+import unittest
179+from collections import namedtuple
180+from tempfile import TemporaryDirectory
181+from unittest.mock import patch
182+
183+from lib import local_users
184+
185+
186+class TestLocalUsers(unittest.TestCase):
187+ @patch("os.chmod")
188+ def test_set_ssh_authorized_key_update(self, _):
189+ testuser = local_users.User(
190+ "testuser", ["Test User", "", "", "", ""], "ssh-rsa ABC testuser@testhost"
191+ )
192+
193+ testuser2 = local_users.User(
194+ "testuser", ["Test User", "", "", "", ""], "ssh-rsa XYZ testuser@testhost"
195+ )
196+
197+ with TemporaryDirectory() as fake_home:
198+ testfile_path = os.path.join(fake_home, "testuser", ".ssh", "authorized_keys")
199+ with patch("lib.local_users.HOME_DIR_PATH", fake_home):
200+ local_users.set_ssh_authorized_key(testuser)
201+ with open(testfile_path, "r") as f:
202+ keys = f.readlines()
203+ self.assertIn("ssh-rsa ABC testuser@testhost # charm-local-users\n", keys)
204+
205+ # update the key
206+ local_users.set_ssh_authorized_key(testuser2)
207+ with open(testfile_path, "r") as f:
208+ keys = f.readlines()
209+ self.assertIn("ssh-rsa XYZ testuser@testhost # charm-local-users\n", keys)
210+ self.assertNotIn("ssh-rsa ABC testuser@testhost # charm-local-users\n", keys)
211+
212+ def test_parse_gecos(self):
213+ test_cases = [
214+ ("", ["", "", "", "", ""]),
215+ ("Test User", ["Test User", "", "", "", ""]),
216+ ("Test,,,,", ["Test", "", "", "", ""]),
217+ ("Test,,", ["Test", "", "", "", ""]),
218+ ("Test,,+0123456789", ["Test", "", "+0123456789", "", ""]),
219+ (",,,", ["", "", "", "", ""]),
220+ (",,,,,,ignored", ["", "", "", "", ""]),
221+ (",,,,'other' field", ["", "", "", "", "'other' field"]),
222+ ]
223+ for tc in test_cases:
224+ result = local_users.parse_gecos(tc[0])
225+ self.assertEqual(result, tc[1])
226+
227+ def test_get_gecos(self):
228+ testcases = [
229+ (
230+ # standard account
231+ "testuser",
232+ b"testuser:x:1000:1000:Test User,,,:/home/testuser:/usr/bin/bash",
233+ ["Test User", "", "", "", ""],
234+ ),
235+ (
236+ # system account, empty GECOS
237+ "svcaccount",
238+ b"svcaccount:x:999:999::/var/lib/svcaccount:/bin/false",
239+ ["", "", "", "", ""],
240+ ),
241+ (
242+ # all fields
243+ "testuser",
244+ b"testuser:x:1000:1000:Test User,ACME,+123,+321,test:/home/testuser:/usr/bin/bash",
245+ ["Test User", "ACME", "+123", "+321", "test"],
246+ ),
247+ (
248+ # one field, no commas
249+ "testuser",
250+ b"testuser:x:1000:1000:Test:home/testuser:/usr/bin/bash",
251+ ["Test", "", "", "", ""],
252+ ),
253+ ]
254+ for tc in testcases:
255+ with patch("subprocess.check_output") as mock_cmd:
256+ mock_cmd.return_value = tc[1]
257+ result = local_users.get_gecos(tc[0])
258+ self.assertEqual(result, tc[2])
259+
260+ def test_update_gecos(self):
261+ # ensure that only fields that changed are passed to chfn
262+ testcase = namedtuple("testcase", ["prev", "updated", "expected", "should_call"])
263+
264+ # NOTE: order of chfn flags in GECOS fields order is: -f -r -w -h -o
265+ testcases = [
266+ testcase(["", "", "", "", ""], ["", "", "", "", ""], [], False),
267+ testcase(["A", "B", "0", "1", "2"], ["A", "B", "0", "1", "2"], [], False),
268+ testcase(
269+ ["A", "B", "0", "1", "2"],
270+ ["A", "X", "9", "1", "2"],
271+ ["-r", "X", "-w", "9"],
272+ True,
273+ ),
274+ testcase(["A", "", "", "", ""], ["B", "", "", "", ""], ["-f", "B"], True),
275+ ]
276+ for tc in testcases:
277+ with patch("lib.local_users.get_gecos") as mock_prev, patch(
278+ "subprocess.check_call"
279+ ) as mock_call:
280+ u = local_users.User("test", tc.updated, "")
281+ mock_prev.return_value = tc.prev
282+ local_users.update_gecos(u)
283+ if tc.should_call:
284+ expected_cmd = ["chfn"] + tc.expected + ["test"]
285+ mock_call.assert_called_once_with(expected_cmd)
286+ else:
287+ mock_call.assert_not_called()
288+
289+ def test_rename_group(self):
290+ with patch("subprocess.check_call") as mock_call:
291+ # the old and the new name are identical - ensure groupmod not called
292+ local_users.rename_group("test1", "test1")
293+ mock_call.assert_not_called()
294+
295+ # ensure it is called when renaming test1 to test2
296+ local_users.rename_group("test1", "test2")
297+ mock_call.assert_called_once_with(["groupmod", "-n", "test2", "test1"])
298+
299+ def test_get_user_membership(self):
300+ with patch("subprocess.check_output") as mock_call:
301+ expected = ["test", "group1", "group2", "group3"]
302+ mock_call.return_value = b"test : test group1 group2 group3"
303+ result = local_users.get_user_membership("test")
304+ self.assertListEqual(result, expected)
305+
306+ def test_get_group_users(self):
307+ with patch("subprocess.check_output") as mock_call:
308+ mock_call.return_value = b"acme:x:1001:test1,test2,test3"
309+ result = local_users.get_group_users("acme")
310+ self.assertListEqual(result, ["test1", "test2", "test3"])
311+
312+ def test_get_group_users_empty(self):
313+ with patch("subprocess.check_output") as mock_call:
314+ mock_call.return_value = b"acme:x:1001:"
315+ result = local_users.get_group_users("acme")
316+ self.assertListEqual(result, [])

Subscribers

People subscribed via source and target branches

to all changes: