mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 11:16:47 +00:00
test: test files named by their topic, one file per topic
- console settings were split over four files; who sees the form, the listen address, and the settings themselves are test_console_settings, and who may open a console is test_console_access; - the three template files are test_templates; - the quota lock test joins test_quota; - accounts' test_security_fixes held two topics: the SSH key tests are test_ssh_keys, the owner-row delete redirect is in test_views; - instances' test_security_mutations, who may change a VM, is test_permissions. The suite runs the same 2097 tests.
This commit is contained in:
parent
bdc8a6755c
commit
f3003bb414
15 changed files with 541 additions and 606 deletions
|
|
@ -1,64 +0,0 @@
|
|||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from accounts.models import UserInstance, UserSSHKey
|
||||
from accounts.utils import validate_ssh_key
|
||||
from computes.models import Compute
|
||||
from instances.models import Instance
|
||||
|
||||
|
||||
class AccountsSecurityFixesTestCase(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.admin = User.objects.create_superuser(
|
||||
username="sec_acc_admin", email="admin@example.com", password="password"
|
||||
)
|
||||
self.user = User.objects.create_user(
|
||||
username="sec_acc_user", password="password"
|
||||
)
|
||||
self.compute = Compute.objects.create(
|
||||
name="acc-comp", hostname="127.0.0.1", login="root", type=1
|
||||
)
|
||||
self.instance = Instance.objects.create(
|
||||
compute=self.compute, name="acc-vm", uuid="99999999-9999-9999-9999-999999999999"
|
||||
)
|
||||
self.user_inst = UserInstance.objects.create(
|
||||
instance=self.instance, user=self.user
|
||||
)
|
||||
|
||||
def test_open_redirect_prevented_in_user_instance_delete(self):
|
||||
self.client.force_login(self.admin)
|
||||
url = reverse("accounts:user_instance_delete", args=[self.user_inst.id])
|
||||
|
||||
# Attempt external malicious redirect
|
||||
res = self.client.post(f"{url}?next=https://malicious-attacker.com")
|
||||
self.assertEqual(res.status_code, 302)
|
||||
# Should redirect to safe internal URL (user account page), NOT external site
|
||||
self.assertNotIn("malicious-attacker.com", res.url)
|
||||
self.assertEqual(res.url, reverse("accounts:account", args=[self.user.id]))
|
||||
|
||||
def test_ssh_key_cascade_deletion(self):
|
||||
key = UserSSHKey.objects.create(
|
||||
user=self.user,
|
||||
keyname="my-key",
|
||||
keypublic="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGabcdef123456 test@example.com",
|
||||
)
|
||||
self.assertTrue(UserSSHKey.objects.filter(id=key.id).exists())
|
||||
|
||||
# When user is deleted, key should be deleted via CASCADE (not left orphan)
|
||||
self.user.delete()
|
||||
self.assertFalse(UserSSHKey.objects.filter(id=key.id).exists())
|
||||
|
||||
def test_validate_ssh_key_supports_keys_with_and_without_comment(self):
|
||||
rsa_base = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQC6OOdbfv27QVnSC6sKxGaHb6YFc+3gxCkyVR3cTSXE/n5BEGf8aOgBpepULWa1RZfxYHY14PlKULDygdXSdrrR2kNSwoKz/Oo4d+3EE92L7ocl1+djZbptzgWgtw1OseLwbFik+iKlIdqPsH+IUQvX7yV545ZQtAP8Qj1R+uCqkw=="
|
||||
# Key with comment
|
||||
key_with_comment = f"{rsa_base} test@test"
|
||||
self.assertTrue(validate_ssh_key(key_with_comment))
|
||||
|
||||
# Key without comment
|
||||
key_without_comment = rsa_base
|
||||
self.assertTrue(validate_ssh_key(key_without_comment))
|
||||
|
||||
# Invalid key
|
||||
self.assertFalse(validate_ssh_key("not a valid key string"))
|
||||
35
accounts/tests/test_ssh_keys.py
Normal file
35
accounts/tests/test_ssh_keys.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"""A user's SSH keys: they go with the user, and a key is valid with or without a comment."""
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
|
||||
from accounts.models import UserSSHKey
|
||||
from accounts.utils import validate_ssh_key
|
||||
|
||||
|
||||
class SshKeyTestCase(TestCase):
|
||||
def test_ssh_key_cascade_deletion(self):
|
||||
user = get_user_model().objects.create_user(username="sec_acc_user", password="password")
|
||||
key = UserSSHKey.objects.create(
|
||||
user=user,
|
||||
keyname="my-key",
|
||||
keypublic="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGabcdef123456 test@example.com",
|
||||
)
|
||||
self.assertTrue(UserSSHKey.objects.filter(id=key.id).exists())
|
||||
|
||||
# When user is deleted, key should be deleted via CASCADE (not left orphan)
|
||||
user.delete()
|
||||
self.assertFalse(UserSSHKey.objects.filter(id=key.id).exists())
|
||||
|
||||
def test_validate_ssh_key_supports_keys_with_and_without_comment(self):
|
||||
rsa_base = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQC6OOdbfv27QVnSC6sKxGaHb6YFc+3gxCkyVR3cTSXE/n5BEGf8aOgBpepULWa1RZfxYHY14PlKULDygdXSdrrR2kNSwoKz/Oo4d+3EE92L7ocl1+djZbptzgWgtw1OseLwbFik+iKlIdqPsH+IUQvX7yV545ZQtAP8Qj1R+uCqkw=="
|
||||
# Key with comment
|
||||
key_with_comment = f"{rsa_base} test@test"
|
||||
self.assertTrue(validate_ssh_key(key_with_comment))
|
||||
|
||||
# Key without comment
|
||||
key_without_comment = rsa_base
|
||||
self.assertTrue(validate_ssh_key(key_without_comment))
|
||||
|
||||
# Invalid key
|
||||
self.assertFalse(validate_ssh_key("not a valid key string"))
|
||||
|
|
@ -335,3 +335,22 @@ class OwnerRightsReturnTestCase(TestCase):
|
|||
with self.subTest(next_url):
|
||||
response = self.client.post(f"{update}?next={next_url}", {"is_change": True})
|
||||
self.assertRedirects(response, back, fetch_redirect_response=False)
|
||||
|
||||
|
||||
class UserInstanceDeleteTestCase(TestCase):
|
||||
def test_open_redirect_prevented_in_user_instance_delete(self):
|
||||
User = get_user_model()
|
||||
admin = User.objects.create_superuser(username="sec_acc_admin", email="admin@example.com", password="password")
|
||||
user = User.objects.create_user(username="sec_acc_user", password="password")
|
||||
compute = Compute.objects.create(name="acc-comp", hostname="127.0.0.1", login="root", type=1)
|
||||
instance = Instance.objects.create(compute=compute, name="acc-vm", uuid="99999999-9999-9999-9999-999999999999")
|
||||
user_inst = UserInstance.objects.create(instance=instance, user=user)
|
||||
self.client.force_login(admin)
|
||||
url = reverse("accounts:user_instance_delete", args=[user_inst.id])
|
||||
|
||||
# Attempt external malicious redirect
|
||||
res = self.client.post(f"{url}?next=https://malicious-attacker.com")
|
||||
self.assertEqual(res.status_code, 302)
|
||||
# Should redirect to safe internal URL (user account page), NOT external site
|
||||
self.assertNotIn("malicious-attacker.com", res.url)
|
||||
self.assertEqual(res.url, reverse("accounts:account", args=[user.id]))
|
||||
|
|
|
|||
|
|
@ -1,84 +0,0 @@
|
|||
"""Only administrators choose where a VM's console listens. Other users who may
|
||||
manage the console cannot make it listen beyond localhost, and cannot remove
|
||||
the password of a console that does; such a refusal saves nothing."""
|
||||
|
||||
from unittest.mock import PropertyMock, patch
|
||||
|
||||
from accounts.models import UserInstance
|
||||
from computes.models import Compute
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.messages import get_messages
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from instances.models import Instance
|
||||
|
||||
|
||||
class ConsoleListenerTests(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.owner = User.objects.create_user("console-owner", password="pw")
|
||||
self.admin = User.objects.create_superuser("console-admin", password="pw", email="ca@example.com")
|
||||
compute = Compute.objects.create(name="cl", hostname="10.0.0.1", login="u", password="p", type=1)
|
||||
self.vm = Instance.objects.create(compute=compute, name="cl-vm", uuid="78787878-0000-0000-0000-000000000000")
|
||||
UserInstance.objects.create(user=self.owner, instance=self.vm, is_change=True, is_vnc=True)
|
||||
|
||||
def post(self, user, data, local=True):
|
||||
self.client.force_login(user)
|
||||
with patch("instances.models.wvmInstance") as wvm:
|
||||
proxy = wvm.return_value
|
||||
proxy.console_listens_locally.return_value = local
|
||||
proxy.get_console_listener_addr.return_value = "127.0.0.1" if local else "0.0.0.0"
|
||||
proxy.get_console_passwd.return_value = "Old-pass-1"
|
||||
proxy.get_console_keymap.return_value = ""
|
||||
proxy.set_console_passwd.return_value = True
|
||||
response = self.client.post(reverse("instances:update_console", args=[self.vm.id]), data)
|
||||
return proxy, [str(m) for m in get_messages(response.wsgi_request)]
|
||||
|
||||
def test_owner_cannot_change_the_listen_address(self):
|
||||
proxy, _ = self.post(self.owner, {"listen_on": "0.0.0.0", "keymap": "auto", "password_action": "keep"})
|
||||
proxy.set_console_listener_addr.assert_not_called()
|
||||
|
||||
def test_owner_cannot_remove_the_password_of_a_console_listening_beyond_localhost(self):
|
||||
proxy, messages = self.post(self.owner, {"password_action": "none", "keymap": "de"}, local=False)
|
||||
proxy.set_console_passwd.assert_not_called()
|
||||
proxy.set_console_keymap.assert_not_called()
|
||||
self.assertTrue(any("password" in m.lower() for m in messages), messages)
|
||||
|
||||
@override_settings(SERIAL_CONSOLE_ENABLED=True)
|
||||
def test_owner_can_switch_a_console_listening_beyond_localhost_to_serial(self):
|
||||
proxy, messages = self.post(self.owner, {"console_type": "pty", "password_action": "none", "keymap": "auto"},
|
||||
local=False)
|
||||
self.assertEqual(messages, [])
|
||||
proxy.set_console_type.assert_called_once_with("pty")
|
||||
proxy.set_console_passwd.assert_not_called()
|
||||
|
||||
def test_owner_can_remove_the_password_of_a_local_console(self):
|
||||
proxy, _ = self.post(self.owner, {"password_action": "none", "keymap": "auto"})
|
||||
proxy.set_console_passwd.assert_called_once_with("")
|
||||
|
||||
def test_owner_can_set_a_password_of_any_console(self):
|
||||
proxy, _ = self.post(self.owner, {"password_action": "set", "password": "s3cret-1", "keymap": "auto"}, local=False)
|
||||
proxy.set_console_passwd.assert_called_once_with("s3cret-1")
|
||||
|
||||
def test_superuser_can_open_the_console_to_all_interfaces(self):
|
||||
proxy, _ = self.post(self.admin, {"listen_on": "0.0.0.0", "password_action": "none", "keymap": "auto"})
|
||||
proxy.set_console_listener_addr.assert_called_once_with("0.0.0.0")
|
||||
proxy.set_console_passwd.assert_called_once_with("")
|
||||
|
||||
def test_owner_sees_the_listen_address_but_cannot_edit_it(self):
|
||||
self.client.force_login(self.owner)
|
||||
with patch("instances.models.wvmInstance") as wvm, patch.object(
|
||||
Compute, "status", new_callable=PropertyMock, return_value=True
|
||||
):
|
||||
proxy = wvm.return_value
|
||||
proxy.get_memory.return_value = 1024
|
||||
proxy.get_cur_memory.return_value = 1024
|
||||
proxy.get_max_memory.return_value = 4096
|
||||
proxy.get_console_listener_addr.return_value = "127.0.0.1"
|
||||
for name in ("get_networks", "get_ifaces", "get_storages", "get_disk_devices", "get_media_devices", "get_net_devices"):
|
||||
getattr(proxy, name).return_value = []
|
||||
proxy.get_hostdevs.return_value = ([], [], [])
|
||||
proxy.get_status.return_value = 5
|
||||
response = self.client.get(reverse("instances:instance", args=[self.vm.id]))
|
||||
form = response.context["console_form"]
|
||||
self.assertTrue(form.fields["listen_on"].disabled)
|
||||
234
instances/tests/test_console_settings.py
Normal file
234
instances/tests/test_console_settings.py
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
"""A VM's console settings: who sees the form; each change is saved to the
|
||||
persistent definition, the password only by the action chosen for it, and
|
||||
the VNC settings only while the saved console type is VNC; only
|
||||
administrators choose where a console listens beyond localhost."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from computes.models import Compute
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.messages import get_messages
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from instances.forms import ConsoleForm
|
||||
from instances.models import Instance
|
||||
from unittest.mock import PropertyMock
|
||||
from accounts.models import UserInstance
|
||||
from django.contrib.auth.models import Permission
|
||||
|
||||
VNC = {"console_type": "vnc", "listen_on": "127.0.0.1", "keymap": "auto", "password_action": "keep"}
|
||||
|
||||
|
||||
class UpdateConsoleTestCase(TestCase):
|
||||
def setUp(self):
|
||||
admin = get_user_model().objects.create_superuser("console_admin", "c@example.com", "pw")
|
||||
self.client.force_login(admin)
|
||||
compute = Compute.objects.create(name="console-compute", hostname="127.0.0.1", login="", password="", type=4)
|
||||
self.instance = Instance.objects.create(
|
||||
compute=compute, name="console-vm", uuid="11111111-2222-3333-4444-555555555555"
|
||||
)
|
||||
|
||||
def post(self, data, console_type="vnc"):
|
||||
"""console_type: the persistent definition's; the live one is VNC."""
|
||||
with patch("instances.models.wvmInstance") as wvm:
|
||||
proxy = wvm.return_value
|
||||
proxy.get_console_type.side_effect = lambda config=False: console_type if config else "vnc"
|
||||
proxy.get_console_listener_addr.return_value = "127.0.0.1"
|
||||
proxy.get_console_keymap.return_value = ""
|
||||
proxy.set_console_passwd.return_value = True
|
||||
response = self.client.post(reverse("instances:update_console", args=[self.instance.id]), data)
|
||||
return proxy, [str(m) for m in get_messages(response.wsgi_request)]
|
||||
|
||||
def test_console_settings_are_applied(self):
|
||||
proxy, _ = self.post({**VNC, "listen_on": "0.0.0.0", "keymap": "de"})
|
||||
proxy.set_console_keymap.assert_called_once_with("de")
|
||||
proxy.set_console_listener_addr.assert_called_once_with("0.0.0.0")
|
||||
proxy.set_console_passwd.assert_not_called()
|
||||
|
||||
def test_the_password_changes_by_the_action_chosen(self):
|
||||
for action, password in (("set", " a b c "), ("set", "éééé"), ("none", "")):
|
||||
with self.subTest(action, password=password):
|
||||
proxy, _ = self.post({**VNC, "password_action": action, "password": password})
|
||||
proxy.set_console_passwd.assert_called_once_with(password)
|
||||
proxy, _ = self.post({**VNC, "password_action": "generate"})
|
||||
(generated,), _ = proxy.set_console_passwd.call_args
|
||||
self.assertEqual(len(generated), 8)
|
||||
|
||||
def test_a_password_vnc_cannot_take_is_refused_and_nothing_is_saved(self):
|
||||
for password in ("", "123456789", "ééééé"): # libvirt counts bytes: é is two
|
||||
with self.subTest(password=password):
|
||||
proxy, messages = self.post({**VNC, "keymap": "de", "password_action": "set", "password": password})
|
||||
proxy.set_console_passwd.assert_not_called()
|
||||
proxy.set_console_keymap.assert_not_called()
|
||||
self.assertTrue(messages[0].startswith("Console settings were not saved"), messages)
|
||||
|
||||
@override_settings(SERIAL_CONSOLE_ENABLED=True)
|
||||
def test_a_new_serial_console_ignores_the_vnc_settings(self):
|
||||
# even ones that would be refused: an empty new password, an unknown keymap
|
||||
for vnc in ({"keymap": "de", "password_action": "none"},
|
||||
{"keymap": "xx", "password_action": "set", "password": ""}):
|
||||
with self.subTest(vnc):
|
||||
proxy, messages = self.post({**VNC, **vnc, "console_type": "pty"})
|
||||
self.assertEqual(messages, [])
|
||||
proxy.set_console_type.assert_called_once_with("pty")
|
||||
proxy.set_console_keymap.assert_not_called()
|
||||
proxy.set_console_passwd.assert_not_called()
|
||||
|
||||
@override_settings(SERIAL_CONSOLE_ENABLED=True)
|
||||
def test_a_saved_serial_console_shows_and_takes_no_vnc_settings(self):
|
||||
# the running VM still has VNC: the type to compare with is the saved one
|
||||
proxy, messages = self.post({"console_type": "pty"}, console_type="pty")
|
||||
self.assertEqual(messages, [])
|
||||
proxy.set_console_type.assert_not_called()
|
||||
proxy, _ = self.post({"console_type": "vnc", "keymap": "de"}, console_type="pty")
|
||||
proxy.set_console_type.assert_called_once_with("vnc")
|
||||
proxy.set_console_keymap.assert_not_called()
|
||||
with patch("instances.models.wvmInstance") as wvm:
|
||||
wvm.return_value.get_console_type.side_effect = lambda config=False: "pty" if config else "vnc"
|
||||
form = ConsoleForm(instance=Instance.objects.get(pk=self.instance.pk), is_superuser=True)
|
||||
self.assertEqual((form["console_type"].value(), sorted(form.fields)), ("pty", ["console_type"]))
|
||||
|
||||
@override_settings(SERIAL_CONSOLE_ENABLED=False)
|
||||
def test_serial_is_not_offered_while_the_serial_console_is_off(self):
|
||||
proxy, _ = self.post({**VNC, "console_type": "pty"})
|
||||
proxy.set_console_type.assert_not_called()
|
||||
|
||||
|
||||
class ConsoleListenerTests(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.owner = User.objects.create_user("console-owner", password="pw")
|
||||
self.admin = User.objects.create_superuser("console-admin", password="pw", email="ca@example.com")
|
||||
compute = Compute.objects.create(name="cl", hostname="10.0.0.1", login="u", password="p", type=1)
|
||||
self.vm = Instance.objects.create(compute=compute, name="cl-vm", uuid="78787878-0000-0000-0000-000000000000")
|
||||
UserInstance.objects.create(user=self.owner, instance=self.vm, is_change=True, is_vnc=True)
|
||||
|
||||
def post(self, user, data, local=True):
|
||||
self.client.force_login(user)
|
||||
with patch("instances.models.wvmInstance") as wvm:
|
||||
proxy = wvm.return_value
|
||||
proxy.console_listens_locally.return_value = local
|
||||
proxy.get_console_listener_addr.return_value = "127.0.0.1" if local else "0.0.0.0"
|
||||
proxy.get_console_passwd.return_value = "Old-pass-1"
|
||||
proxy.get_console_keymap.return_value = ""
|
||||
proxy.set_console_passwd.return_value = True
|
||||
response = self.client.post(reverse("instances:update_console", args=[self.vm.id]), data)
|
||||
return proxy, [str(m) for m in get_messages(response.wsgi_request)]
|
||||
|
||||
def test_owner_cannot_change_the_listen_address(self):
|
||||
proxy, _ = self.post(self.owner, {"listen_on": "0.0.0.0", "keymap": "auto", "password_action": "keep"})
|
||||
proxy.set_console_listener_addr.assert_not_called()
|
||||
|
||||
def test_owner_cannot_remove_the_password_of_a_console_listening_beyond_localhost(self):
|
||||
proxy, messages = self.post(self.owner, {"password_action": "none", "keymap": "de"}, local=False)
|
||||
proxy.set_console_passwd.assert_not_called()
|
||||
proxy.set_console_keymap.assert_not_called()
|
||||
self.assertTrue(any("password" in m.lower() for m in messages), messages)
|
||||
|
||||
@override_settings(SERIAL_CONSOLE_ENABLED=True)
|
||||
def test_owner_can_switch_a_console_listening_beyond_localhost_to_serial(self):
|
||||
proxy, messages = self.post(self.owner, {"console_type": "pty", "password_action": "none", "keymap": "auto"},
|
||||
local=False)
|
||||
self.assertEqual(messages, [])
|
||||
proxy.set_console_type.assert_called_once_with("pty")
|
||||
proxy.set_console_passwd.assert_not_called()
|
||||
|
||||
def test_owner_can_remove_the_password_of_a_local_console(self):
|
||||
proxy, _ = self.post(self.owner, {"password_action": "none", "keymap": "auto"})
|
||||
proxy.set_console_passwd.assert_called_once_with("")
|
||||
|
||||
def test_owner_can_set_a_password_of_any_console(self):
|
||||
proxy, _ = self.post(self.owner, {"password_action": "set", "password": "s3cret-1", "keymap": "auto"}, local=False)
|
||||
proxy.set_console_passwd.assert_called_once_with("s3cret-1")
|
||||
|
||||
def test_superuser_can_open_the_console_to_all_interfaces(self):
|
||||
proxy, _ = self.post(self.admin, {"listen_on": "0.0.0.0", "password_action": "none", "keymap": "auto"})
|
||||
proxy.set_console_listener_addr.assert_called_once_with("0.0.0.0")
|
||||
proxy.set_console_passwd.assert_called_once_with("")
|
||||
|
||||
def test_owner_sees_the_listen_address_but_cannot_edit_it(self):
|
||||
self.client.force_login(self.owner)
|
||||
with patch("instances.models.wvmInstance") as wvm, patch.object(
|
||||
Compute, "status", new_callable=PropertyMock, return_value=True
|
||||
):
|
||||
proxy = wvm.return_value
|
||||
proxy.get_memory.return_value = 1024
|
||||
proxy.get_cur_memory.return_value = 1024
|
||||
proxy.get_max_memory.return_value = 4096
|
||||
proxy.get_console_listener_addr.return_value = "127.0.0.1"
|
||||
for name in ("get_networks", "get_ifaces", "get_storages", "get_disk_devices", "get_media_devices", "get_net_devices"):
|
||||
getattr(proxy, name).return_value = []
|
||||
proxy.get_hostdevs.return_value = ([], [], [])
|
||||
proxy.get_status.return_value = 5
|
||||
response = self.client.get(reverse("instances:instance", args=[self.vm.id]))
|
||||
form = response.context["console_form"]
|
||||
self.assertTrue(form.fields["listen_on"].disabled)
|
||||
|
||||
|
||||
VNC_PASSWORD = "s3cretVNCpass"
|
||||
|
||||
|
||||
class ConsoleSettingsAccessTestCase(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
view_perm = Permission.objects.get(codename="view_instances")
|
||||
self.superuser = User.objects.create_superuser(
|
||||
username="vncpw_super", email="vncpw_super@example.com", password="x"
|
||||
)
|
||||
self.staff_viewer = User.objects.create_user(
|
||||
username="vncpw_staff_viewer", password="x", is_staff=True
|
||||
)
|
||||
self.staff_viewer.user_permissions.add(view_perm)
|
||||
self.staff_owner = User.objects.create_user(
|
||||
username="vncpw_staff_owner", password="x", is_staff=True
|
||||
)
|
||||
self.vnc_owner = User.objects.create_user(username="vncpw_vnc_owner", password="x")
|
||||
|
||||
self.compute = Compute.objects.create(
|
||||
name="vncpw-compute", hostname="127.0.0.1:1", login="root", password="", type=1
|
||||
)
|
||||
self.instance = Instance.objects.create(
|
||||
compute=self.compute,
|
||||
name="vncpw-vm",
|
||||
uuid="cccccccc-dddd-eeee-ffff-000000000000",
|
||||
)
|
||||
UserInstance.objects.create(
|
||||
instance=self.instance, user=self.staff_owner, is_change=True, is_vnc=False
|
||||
)
|
||||
UserInstance.objects.create(
|
||||
instance=self.instance, user=self.vnc_owner, is_change=True, is_vnc=True
|
||||
)
|
||||
|
||||
def _detail_page(self, user):
|
||||
self.client.force_login(user)
|
||||
with patch("instances.models.wvmInstance") as mock_wvm, patch.object(
|
||||
Compute, "status", new_callable=PropertyMock, return_value=True
|
||||
):
|
||||
proxy = mock_wvm.return_value
|
||||
proxy.get_memory.return_value = 1024
|
||||
proxy.get_cur_memory.return_value = 1024
|
||||
proxy.get_max_memory.return_value = 4096
|
||||
proxy.get_networks.return_value = []
|
||||
proxy.get_ifaces.return_value = []
|
||||
proxy.get_hostdevs.return_value = ([], [], [])
|
||||
proxy.get_storages.return_value = []
|
||||
proxy.get_console_passwd.return_value = VNC_PASSWORD
|
||||
proxy.get_console_type.return_value = "vnc"
|
||||
proxy.get_status.return_value = 5
|
||||
proxy.get_disk_devices.return_value = []
|
||||
proxy.get_media_devices.return_value = []
|
||||
proxy.get_net_devices.return_value = []
|
||||
response = self.client.get(
|
||||
reverse("instances:instance", args=[self.instance.id])
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
return response.content.decode()
|
||||
|
||||
def test_only_users_who_may_change_the_console_see_its_settings(self):
|
||||
form = reverse("instances:update_console", args=[self.instance.id])
|
||||
for user, shown in ((self.staff_viewer, False), (self.staff_owner, False),
|
||||
(self.vnc_owner, True), (self.superuser, True)):
|
||||
with self.subTest(user.username):
|
||||
page = self._detail_page(user)
|
||||
self.assertEqual(form in page, shown)
|
||||
self.assertNotIn(VNC_PASSWORD, page)
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
"""
|
||||
The console settings form is rendered only for users who may change console
|
||||
settings, which is the rule update_console enforces: a superuser, or an owner
|
||||
with both is_change and is_vnc; not for is_staff or the global view_instances
|
||||
permission. The form never carries the VNC password: it says whether one is
|
||||
set.
|
||||
"""
|
||||
from unittest.mock import PropertyMock, patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from accounts.models import UserInstance
|
||||
from computes.models import Compute
|
||||
from instances.models import Instance
|
||||
|
||||
VNC_PASSWORD = "s3cretVNCpass"
|
||||
|
||||
|
||||
class ConsoleSettingsAccessTestCase(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
view_perm = Permission.objects.get(codename="view_instances")
|
||||
self.superuser = User.objects.create_superuser(
|
||||
username="vncpw_super", email="vncpw_super@example.com", password="x"
|
||||
)
|
||||
self.staff_viewer = User.objects.create_user(
|
||||
username="vncpw_staff_viewer", password="x", is_staff=True
|
||||
)
|
||||
self.staff_viewer.user_permissions.add(view_perm)
|
||||
self.staff_owner = User.objects.create_user(
|
||||
username="vncpw_staff_owner", password="x", is_staff=True
|
||||
)
|
||||
self.vnc_owner = User.objects.create_user(username="vncpw_vnc_owner", password="x")
|
||||
|
||||
self.compute = Compute.objects.create(
|
||||
name="vncpw-compute", hostname="127.0.0.1:1", login="root", password="", type=1
|
||||
)
|
||||
self.instance = Instance.objects.create(
|
||||
compute=self.compute,
|
||||
name="vncpw-vm",
|
||||
uuid="cccccccc-dddd-eeee-ffff-000000000000",
|
||||
)
|
||||
UserInstance.objects.create(
|
||||
instance=self.instance, user=self.staff_owner, is_change=True, is_vnc=False
|
||||
)
|
||||
UserInstance.objects.create(
|
||||
instance=self.instance, user=self.vnc_owner, is_change=True, is_vnc=True
|
||||
)
|
||||
|
||||
def _detail_page(self, user):
|
||||
self.client.force_login(user)
|
||||
with patch("instances.models.wvmInstance") as mock_wvm, patch.object(
|
||||
Compute, "status", new_callable=PropertyMock, return_value=True
|
||||
):
|
||||
proxy = mock_wvm.return_value
|
||||
proxy.get_memory.return_value = 1024
|
||||
proxy.get_cur_memory.return_value = 1024
|
||||
proxy.get_max_memory.return_value = 4096
|
||||
proxy.get_networks.return_value = []
|
||||
proxy.get_ifaces.return_value = []
|
||||
proxy.get_hostdevs.return_value = ([], [], [])
|
||||
proxy.get_storages.return_value = []
|
||||
proxy.get_console_passwd.return_value = VNC_PASSWORD
|
||||
proxy.get_console_type.return_value = "vnc"
|
||||
proxy.get_status.return_value = 5
|
||||
proxy.get_disk_devices.return_value = []
|
||||
proxy.get_media_devices.return_value = []
|
||||
proxy.get_net_devices.return_value = []
|
||||
response = self.client.get(
|
||||
reverse("instances:instance", args=[self.instance.id])
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
return response.content.decode()
|
||||
|
||||
def test_only_users_who_may_change_the_console_see_its_settings(self):
|
||||
form = reverse("instances:update_console", args=[self.instance.id])
|
||||
for user, shown in ((self.staff_viewer, False), (self.staff_owner, False),
|
||||
(self.vnc_owner, True), (self.superuser, True)):
|
||||
with self.subTest(user.username):
|
||||
page = self._detail_page(user)
|
||||
self.assertEqual(form in page, shown)
|
||||
self.assertNotIn(VNC_PASSWORD, page)
|
||||
|
|
@ -18,6 +18,10 @@ from libvirt import VIR_DOMAIN_PAUSED, VIR_DOMAIN_RUNNING
|
|||
from operations.models import Operation
|
||||
from vrtManager.util import OperationError
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from django.db import connection
|
||||
from django.test import Client, TransactionTestCase, override_settings
|
||||
|
||||
|
||||
def vm_reader(vcpu=2, memory=1024, disk_gib=10, pending=None):
|
||||
|
|
@ -292,3 +296,58 @@ class QuotaWithDownHostTestCase(TestCase):
|
|||
get_settings() # app settings are normally loaded by middleware
|
||||
|
||||
self.assertEqual(check_user_quota(user, 1, 1, 128, 1), QUOTA_UNVERIFIED)
|
||||
|
||||
|
||||
QUOTA_CPUS = 4
|
||||
|
||||
|
||||
# Sessions and messages in cookies: concurrent writes to the in-memory test
|
||||
# database would fail for reasons unrelated to the quota race.
|
||||
@override_settings(
|
||||
SESSION_ENGINE="django.contrib.sessions.backends.signed_cookies",
|
||||
MESSAGE_STORAGE="django.contrib.messages.storage.cookie.CookieStorage",
|
||||
)
|
||||
|
||||
|
||||
class QuotaRaceTestCase(TransactionTestCase):
|
||||
"""Two concurrent resizes of one user's VMs must not both pass the quota."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = get_user_model().objects.create_user("quota_user", password="pw")
|
||||
compute = Compute.objects.create(name="quota-compute", hostname="127.0.0.1", login="", password="", type=4)
|
||||
self.vms = [
|
||||
Instance.objects.create(compute=compute, name=f"quota-vm-{i}", uuid=f"1111111{i}-2222-3333-4444-555555555555")
|
||||
for i in range(2)
|
||||
]
|
||||
for vm in self.vms:
|
||||
UserInstance.objects.create(user=self.user, instance=vm, is_change=True)
|
||||
self.allocated = 2 # two VMs with one vCPU each
|
||||
|
||||
def fake_quota(self, user, instance, cpu, memory, disk):
|
||||
seen = self.allocated
|
||||
time.sleep(0.3) # widen the window between the check and the change
|
||||
return "cpu" if seen + cpu > QUOTA_CPUS else ""
|
||||
|
||||
def fake_resize(self, cur_vcpu, vcpu):
|
||||
self.allocated += int(vcpu) - 1
|
||||
|
||||
def resize(self, vm):
|
||||
client = Client()
|
||||
client.force_login(self.user)
|
||||
client.post(reverse("instances:resizevm_cpu", args=[vm.id]), {"vcpu": 3, "cur_vcpu": 3})
|
||||
connection.close()
|
||||
|
||||
def test_second_concurrent_resize_is_refused(self):
|
||||
with patch("instances.views.utils.check_user_quota", self.fake_quota), patch(
|
||||
"instances.models.wvmInstance"
|
||||
) as wvm:
|
||||
wvm.return_value.get_vcpu.return_value = 1
|
||||
wvm.return_value.instance.isActive.return_value = False # vCPUs change on a shut-off VM
|
||||
wvm.return_value.resize_cpu.side_effect = self.fake_resize
|
||||
threads = [threading.Thread(target=self.resize, args=(vm,)) for vm in self.vms]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
self.assertEqual(wvm.return_value.resize_cpu.call_count, 1)
|
||||
self.assertLessEqual(self.allocated, QUOTA_CPUS)
|
||||
|
|
|
|||
|
|
@ -1,63 +0,0 @@
|
|||
import threading
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
from accounts.models import UserInstance
|
||||
from computes.models import Compute
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import connection
|
||||
from django.test import Client, TransactionTestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from instances.models import Instance
|
||||
|
||||
QUOTA_CPUS = 4
|
||||
|
||||
|
||||
# Sessions and messages in cookies: concurrent writes to the in-memory test
|
||||
# database would fail for reasons unrelated to the quota race.
|
||||
@override_settings(
|
||||
SESSION_ENGINE="django.contrib.sessions.backends.signed_cookies",
|
||||
MESSAGE_STORAGE="django.contrib.messages.storage.cookie.CookieStorage",
|
||||
)
|
||||
class QuotaRaceTestCase(TransactionTestCase):
|
||||
"""Two concurrent resizes of one user's VMs must not both pass the quota."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = get_user_model().objects.create_user("quota_user", password="pw")
|
||||
compute = Compute.objects.create(name="quota-compute", hostname="127.0.0.1", login="", password="", type=4)
|
||||
self.vms = [
|
||||
Instance.objects.create(compute=compute, name=f"quota-vm-{i}", uuid=f"1111111{i}-2222-3333-4444-555555555555")
|
||||
for i in range(2)
|
||||
]
|
||||
for vm in self.vms:
|
||||
UserInstance.objects.create(user=self.user, instance=vm, is_change=True)
|
||||
self.allocated = 2 # two VMs with one vCPU each
|
||||
|
||||
def fake_quota(self, user, instance, cpu, memory, disk):
|
||||
seen = self.allocated
|
||||
time.sleep(0.3) # widen the window between the check and the change
|
||||
return "cpu" if seen + cpu > QUOTA_CPUS else ""
|
||||
|
||||
def fake_resize(self, cur_vcpu, vcpu):
|
||||
self.allocated += int(vcpu) - 1
|
||||
|
||||
def resize(self, vm):
|
||||
client = Client()
|
||||
client.force_login(self.user)
|
||||
client.post(reverse("instances:resizevm_cpu", args=[vm.id]), {"vcpu": 3, "cur_vcpu": 3})
|
||||
connection.close()
|
||||
|
||||
def test_second_concurrent_resize_is_refused(self):
|
||||
with patch("instances.views.utils.check_user_quota", self.fake_quota), patch(
|
||||
"instances.models.wvmInstance"
|
||||
) as wvm:
|
||||
wvm.return_value.get_vcpu.return_value = 1
|
||||
wvm.return_value.instance.isActive.return_value = False # vCPUs change on a shut-off VM
|
||||
wvm.return_value.resize_cpu.side_effect = self.fake_resize
|
||||
threads = [threading.Thread(target=self.resize, args=(vm,)) for vm in self.vms]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
self.assertEqual(wvm.return_value.resize_cpu.call_count, 1)
|
||||
self.assertLessEqual(self.allocated, QUOTA_CPUS)
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
"""
|
||||
Only superusers and staff may mark or unmark a VM as a template, on its own
|
||||
form: the title and description form leaves the flag alone.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from accounts.models import UserInstance
|
||||
from computes.models import Compute
|
||||
from instances.models import Instance
|
||||
from logs.models import Logs
|
||||
|
||||
|
||||
class TemplateFlagTestCase(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.superuser = User.objects.create_superuser(
|
||||
username="tpl_super", email="tpl_super@example.com", password="x"
|
||||
)
|
||||
self.owner = User.objects.create_user(username="tpl_owner", password="x")
|
||||
self.staff_owner = User.objects.create_user(
|
||||
username="tpl_staff_owner", password="x", is_staff=True
|
||||
)
|
||||
self.compute = Compute.objects.create(
|
||||
name="tpl-compute", hostname="127.0.0.1:1", login="root", password="", type=1
|
||||
)
|
||||
self.instance = Instance.objects.create(
|
||||
compute=self.compute,
|
||||
name="tpl-vm",
|
||||
uuid="dddddddd-eeee-ffff-0000-111111111111",
|
||||
)
|
||||
for user in (self.owner, self.staff_owner):
|
||||
UserInstance.objects.create(instance=self.instance, user=user, is_change=True)
|
||||
|
||||
def _post(self, user, action, data, status=302):
|
||||
self.client.force_login(user)
|
||||
with patch("instances.models.wvmInstance") as wvm:
|
||||
wvm.return_value.get_status.return_value = 5
|
||||
# unmarking a template checks the owner's quota with the VM's size
|
||||
wvm.return_value.get_vcpu.return_value = 1
|
||||
wvm.return_value.get_memory.return_value = 1024
|
||||
wvm.return_value.get_disk_devices.return_value = []
|
||||
res = self.client.post(reverse(f"instances:{action}", args=[self.instance.id]), data)
|
||||
self.assertEqual(res.status_code, status)
|
||||
self.instance.refresh_from_db()
|
||||
|
||||
def _make_template(self):
|
||||
self.instance.is_template = True
|
||||
self.instance.save(update_fields=["is_template"])
|
||||
|
||||
def test_owner_cannot_mark_vm_as_template(self):
|
||||
self._post(self.owner, "set_template", {"is_template": "True"}, status=403)
|
||||
self.assertFalse(self.instance.is_template)
|
||||
|
||||
def test_owner_cannot_change_a_template(self):
|
||||
# a template is changed only by superusers and staff owners
|
||||
self._make_template()
|
||||
self._post(self.owner, "change_options", {"title": "new title", "description": ""}, status=403)
|
||||
self.assertTrue(self.instance.is_template)
|
||||
|
||||
def test_staff_owner_can_mark_and_unmark_template(self):
|
||||
self._post(self.staff_owner, "set_template", {"is_template": "True"})
|
||||
self.assertTrue(self.instance.is_template)
|
||||
# an unchecked switch is not submitted: "not a template"
|
||||
self._post(self.staff_owner, "set_template", {})
|
||||
self.assertFalse(self.instance.is_template)
|
||||
self.assertEqual(list(Logs.objects.filter(message__startswith="VM").order_by("id").values_list("message", "success")),
|
||||
[("VM tpl-vm: mark as template", True), ("VM tpl-vm: unmark as template", True)])
|
||||
|
||||
def test_superuser_can_unmark_template(self):
|
||||
self._make_template()
|
||||
self._post(self.superuser, "set_template", {})
|
||||
self.assertFalse(self.instance.is_template)
|
||||
|
||||
def test_the_title_form_leaves_the_template_alone(self):
|
||||
self._make_template()
|
||||
self._post(self.superuser, "change_options", {"title": "t", "description": ""})
|
||||
self.assertTrue(self.instance.is_template)
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
"""Templates do not count towards the quota and cannot be started. Turning a
|
||||
template back into a VM adds it to its owner's usage, so for users with a
|
||||
quota it is checked like any other increase. Only a shut-off VM changes."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from accounts.models import UserInstance
|
||||
from computes.models import Compute
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.messages import get_messages
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from instances.models import Instance
|
||||
from instances.utils import QUOTA_UNVERIFIED, QuotaExceeded
|
||||
|
||||
|
||||
class TemplateFlagQuotaTests(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.staff = User.objects.create_user("tq-staff", password="pw", is_staff=True)
|
||||
compute = Compute.objects.create(name="tq", hostname="10.0.0.5", login="u", password="p", type=1)
|
||||
self.vm = Instance.objects.create(compute=compute, name="tq-vm", uuid="12345678-0000-0000-0000-00000000aaaa")
|
||||
UserInstance.objects.create(user=self.staff, instance=self.vm, is_change=True)
|
||||
|
||||
def post(self, user, is_template, quota="", status=5):
|
||||
self.client.force_login(user)
|
||||
data = {"is_template": "on"} if is_template else {}
|
||||
with patch("instances.models.wvmInstance") as wvm, patch(
|
||||
"instances.views.utils.check_user_quota", return_value=quota
|
||||
) as check:
|
||||
wvm.return_value.get_status.return_value = status
|
||||
wvm.return_value.get_vcpu.return_value = 2
|
||||
wvm.return_value.get_memory.return_value = 2048
|
||||
wvm.return_value.get_disk_devices.return_value = [{"size": 10 << 30}]
|
||||
response = self.client.post(reverse("instances:set_template", args=[self.vm.id]), data)
|
||||
self.vm.refresh_from_db()
|
||||
return check, [str(m) for m in get_messages(response.wsgi_request)]
|
||||
|
||||
def test_making_a_template_needs_no_quota(self):
|
||||
check, _ = self.post(self.staff, is_template=True)
|
||||
self.assertTrue(self.vm.is_template)
|
||||
check.assert_not_called()
|
||||
|
||||
def test_turning_a_template_into_a_vm_is_checked(self):
|
||||
Instance.objects.filter(pk=self.vm.pk).update(is_template=True)
|
||||
check, _ = self.post(self.staff, is_template=False)
|
||||
self.assertFalse(self.vm.is_template)
|
||||
check.assert_called_once_with(self.staff, 1, 2, 2048, 10)
|
||||
|
||||
def test_over_quota_the_template_stays_a_template(self):
|
||||
Instance.objects.filter(pk=self.vm.pk).update(is_template=True)
|
||||
_, messages = self.post(self.staff, is_template=False, quota=QuotaExceeded("cpu", 9, 8))
|
||||
self.assertTrue(self.vm.is_template)
|
||||
self.assertTrue(any("quota" in m for m in messages), messages)
|
||||
|
||||
def test_an_unverifiable_quota_keeps_it_a_template(self):
|
||||
Instance.objects.filter(pk=self.vm.pk).update(is_template=True)
|
||||
_, messages = self.post(self.staff, is_template=False, quota=QUOTA_UNVERIFIED)
|
||||
self.assertTrue(self.vm.is_template)
|
||||
self.assertTrue(any("cannot be reached" in m for m in messages), messages)
|
||||
|
||||
def test_a_vm_that_is_not_shut_off_stays_as_it_is(self):
|
||||
# a running VM made a template would leave the quota while it runs
|
||||
for status in (1, 3):
|
||||
with self.subTest(status=status):
|
||||
check, messages = self.post(self.staff, is_template=True, status=status)
|
||||
self.assertFalse(self.vm.is_template)
|
||||
self.assertIn("Shut the VM down to change whether it is a template.", messages)
|
||||
|
||||
def test_superusers_have_no_quota(self):
|
||||
Instance.objects.filter(pk=self.vm.pk).update(is_template=True)
|
||||
admin = get_user_model().objects.create_superuser("tq-admin", "tq@example.com", "pw")
|
||||
check, _ = self.post(admin, is_template=False, quota=QuotaExceeded("cpu", 9, 8))
|
||||
self.assertFalse(self.vm.is_template)
|
||||
check.assert_not_called()
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
"""
|
||||
A template is not started (power on or power cycle, web or API) and is
|
||||
changed or deleted only by superusers and staff owners.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from accounts.models import UserInstance
|
||||
from computes.models import Compute
|
||||
from instances.models import Instance
|
||||
from operations.models import Operation
|
||||
|
||||
|
||||
class TemplateRulesTestCase(TestCase):
|
||||
def setUp(self):
|
||||
compute = Compute.objects.create(name="tpl-c", hostname="192.0.2.5", login="root", password="", type=1)
|
||||
self.template = Instance.objects.create(
|
||||
compute=compute, name="tpl-vm", uuid="ffffffff-0000-1111-2222-333333333333", is_template=True
|
||||
)
|
||||
self.owner = get_user_model().objects.create_user("tpl_owner", password="x")
|
||||
UserInstance.objects.create(instance=self.template, user=self.owner, is_delete=True)
|
||||
self.staff = get_user_model().objects.create_user("tpl_staff", password="x", is_staff=True)
|
||||
UserInstance.objects.create(instance=self.template, user=self.staff, is_delete=True)
|
||||
|
||||
def test_power_cycle_does_not_start_a_template(self):
|
||||
api = APIClient()
|
||||
api.force_login(self.owner)
|
||||
self.client.force_login(self.owner)
|
||||
for post in (
|
||||
lambda: self.client.post(reverse("instances:powercycle", args=[self.template.id])),
|
||||
lambda: api.post(reverse("compute-instance-powercycle", args=[self.template.compute_id, self.template.id])),
|
||||
):
|
||||
with patch("instances.models.wvmInstance") as wvm:
|
||||
post()
|
||||
self.assertFalse(wvm.return_value.start.called)
|
||||
self.assertFalse(wvm.return_value.force_shutdown.called)
|
||||
|
||||
def test_only_staff_owners_delete_a_template(self):
|
||||
self.client.force_login(self.owner)
|
||||
with patch("instances.models.wvmInstance") as wvm:
|
||||
response = self.client.post(reverse("instances:destroy", args=[self.template.id]))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertFalse(wvm.return_value.delete.called)
|
||||
|
||||
self.client.force_login(self.staff)
|
||||
with patch("instances.models.wvmInstance"):
|
||||
self.client.post(reverse("instances:destroy", args=[self.template.id]), {"submission_key": "key-tpldel01"})
|
||||
self.assertTrue(Operation.objects.filter(kind="instance.delete").exists()) # queued
|
||||
|
||||
def test_the_destroy_confirmation_page_offers_the_form_to_who_may_delete(self):
|
||||
url = reverse("instances:destroy", args=[self.template.id])
|
||||
superuser = get_user_model().objects.create_superuser("tpl_root", "r@example.com", "x")
|
||||
for user, status, form in ((superuser, 200, True), (self.staff, 200, True), (self.owner, 403, False)):
|
||||
with self.subTest(user=user.username):
|
||||
self.client.force_login(user)
|
||||
with patch("instances.models.wvmInstance") as wvm:
|
||||
wvm.return_value.get_status.return_value = 5
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status)
|
||||
self.assertEqual('id="delete_form"' in response.content.decode(), form)
|
||||
194
instances/tests/test_templates.py
Normal file
194
instances/tests/test_templates.py
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
"""Templates: only superusers and staff mark or unmark one; a template is
|
||||
not started, does not count towards the quota (turning it back into a VM
|
||||
is checked), and is changed or deleted only by superusers and staff owners."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from accounts.models import UserInstance
|
||||
from computes.models import Compute
|
||||
from instances.models import Instance
|
||||
from operations.models import Operation
|
||||
from logs.models import Logs
|
||||
from django.contrib.messages import get_messages
|
||||
from instances.utils import QUOTA_UNVERIFIED, QuotaExceeded
|
||||
|
||||
|
||||
class TemplateRulesTestCase(TestCase):
|
||||
def setUp(self):
|
||||
compute = Compute.objects.create(name="tpl-c", hostname="192.0.2.5", login="root", password="", type=1)
|
||||
self.template = Instance.objects.create(
|
||||
compute=compute, name="tpl-vm", uuid="ffffffff-0000-1111-2222-333333333333", is_template=True
|
||||
)
|
||||
self.owner = get_user_model().objects.create_user("tpl_owner", password="x")
|
||||
UserInstance.objects.create(instance=self.template, user=self.owner, is_delete=True)
|
||||
self.staff = get_user_model().objects.create_user("tpl_staff", password="x", is_staff=True)
|
||||
UserInstance.objects.create(instance=self.template, user=self.staff, is_delete=True)
|
||||
|
||||
def test_power_cycle_does_not_start_a_template(self):
|
||||
api = APIClient()
|
||||
api.force_login(self.owner)
|
||||
self.client.force_login(self.owner)
|
||||
for post in (
|
||||
lambda: self.client.post(reverse("instances:powercycle", args=[self.template.id])),
|
||||
lambda: api.post(reverse("compute-instance-powercycle", args=[self.template.compute_id, self.template.id])),
|
||||
):
|
||||
with patch("instances.models.wvmInstance") as wvm:
|
||||
post()
|
||||
self.assertFalse(wvm.return_value.start.called)
|
||||
self.assertFalse(wvm.return_value.force_shutdown.called)
|
||||
|
||||
def test_only_staff_owners_delete_a_template(self):
|
||||
self.client.force_login(self.owner)
|
||||
with patch("instances.models.wvmInstance") as wvm:
|
||||
response = self.client.post(reverse("instances:destroy", args=[self.template.id]))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertFalse(wvm.return_value.delete.called)
|
||||
|
||||
self.client.force_login(self.staff)
|
||||
with patch("instances.models.wvmInstance"):
|
||||
self.client.post(reverse("instances:destroy", args=[self.template.id]), {"submission_key": "key-tpldel01"})
|
||||
self.assertTrue(Operation.objects.filter(kind="instance.delete").exists()) # queued
|
||||
|
||||
def test_the_destroy_confirmation_page_offers_the_form_to_who_may_delete(self):
|
||||
url = reverse("instances:destroy", args=[self.template.id])
|
||||
superuser = get_user_model().objects.create_superuser("tpl_root", "r@example.com", "x")
|
||||
for user, status, form in ((superuser, 200, True), (self.staff, 200, True), (self.owner, 403, False)):
|
||||
with self.subTest(user=user.username):
|
||||
self.client.force_login(user)
|
||||
with patch("instances.models.wvmInstance") as wvm:
|
||||
wvm.return_value.get_status.return_value = 5
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status)
|
||||
self.assertEqual('id="delete_form"' in response.content.decode(), form)
|
||||
|
||||
|
||||
class TemplateFlagTestCase(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.superuser = User.objects.create_superuser(
|
||||
username="tpl_super", email="tpl_super@example.com", password="x"
|
||||
)
|
||||
self.owner = User.objects.create_user(username="tpl_owner", password="x")
|
||||
self.staff_owner = User.objects.create_user(
|
||||
username="tpl_staff_owner", password="x", is_staff=True
|
||||
)
|
||||
self.compute = Compute.objects.create(
|
||||
name="tpl-compute", hostname="127.0.0.1:1", login="root", password="", type=1
|
||||
)
|
||||
self.instance = Instance.objects.create(
|
||||
compute=self.compute,
|
||||
name="tpl-vm",
|
||||
uuid="dddddddd-eeee-ffff-0000-111111111111",
|
||||
)
|
||||
for user in (self.owner, self.staff_owner):
|
||||
UserInstance.objects.create(instance=self.instance, user=user, is_change=True)
|
||||
|
||||
def _post(self, user, action, data, status=302):
|
||||
self.client.force_login(user)
|
||||
with patch("instances.models.wvmInstance") as wvm:
|
||||
wvm.return_value.get_status.return_value = 5
|
||||
# unmarking a template checks the owner's quota with the VM's size
|
||||
wvm.return_value.get_vcpu.return_value = 1
|
||||
wvm.return_value.get_memory.return_value = 1024
|
||||
wvm.return_value.get_disk_devices.return_value = []
|
||||
res = self.client.post(reverse(f"instances:{action}", args=[self.instance.id]), data)
|
||||
self.assertEqual(res.status_code, status)
|
||||
self.instance.refresh_from_db()
|
||||
|
||||
def _make_template(self):
|
||||
self.instance.is_template = True
|
||||
self.instance.save(update_fields=["is_template"])
|
||||
|
||||
def test_owner_cannot_mark_vm_as_template(self):
|
||||
self._post(self.owner, "set_template", {"is_template": "True"}, status=403)
|
||||
self.assertFalse(self.instance.is_template)
|
||||
|
||||
def test_owner_cannot_change_a_template(self):
|
||||
# a template is changed only by superusers and staff owners
|
||||
self._make_template()
|
||||
self._post(self.owner, "change_options", {"title": "new title", "description": ""}, status=403)
|
||||
self.assertTrue(self.instance.is_template)
|
||||
|
||||
def test_staff_owner_can_mark_and_unmark_template(self):
|
||||
self._post(self.staff_owner, "set_template", {"is_template": "True"})
|
||||
self.assertTrue(self.instance.is_template)
|
||||
# an unchecked switch is not submitted: "not a template"
|
||||
self._post(self.staff_owner, "set_template", {})
|
||||
self.assertFalse(self.instance.is_template)
|
||||
self.assertEqual(list(Logs.objects.filter(message__startswith="VM").order_by("id").values_list("message", "success")),
|
||||
[("VM tpl-vm: mark as template", True), ("VM tpl-vm: unmark as template", True)])
|
||||
|
||||
def test_superuser_can_unmark_template(self):
|
||||
self._make_template()
|
||||
self._post(self.superuser, "set_template", {})
|
||||
self.assertFalse(self.instance.is_template)
|
||||
|
||||
def test_the_title_form_leaves_the_template_alone(self):
|
||||
self._make_template()
|
||||
self._post(self.superuser, "change_options", {"title": "t", "description": ""})
|
||||
self.assertTrue(self.instance.is_template)
|
||||
|
||||
|
||||
class TemplateFlagQuotaTests(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.staff = User.objects.create_user("tq-staff", password="pw", is_staff=True)
|
||||
compute = Compute.objects.create(name="tq", hostname="10.0.0.5", login="u", password="p", type=1)
|
||||
self.vm = Instance.objects.create(compute=compute, name="tq-vm", uuid="12345678-0000-0000-0000-00000000aaaa")
|
||||
UserInstance.objects.create(user=self.staff, instance=self.vm, is_change=True)
|
||||
|
||||
def post(self, user, is_template, quota="", status=5):
|
||||
self.client.force_login(user)
|
||||
data = {"is_template": "on"} if is_template else {}
|
||||
with patch("instances.models.wvmInstance") as wvm, patch(
|
||||
"instances.views.utils.check_user_quota", return_value=quota
|
||||
) as check:
|
||||
wvm.return_value.get_status.return_value = status
|
||||
wvm.return_value.get_vcpu.return_value = 2
|
||||
wvm.return_value.get_memory.return_value = 2048
|
||||
wvm.return_value.get_disk_devices.return_value = [{"size": 10 << 30}]
|
||||
response = self.client.post(reverse("instances:set_template", args=[self.vm.id]), data)
|
||||
self.vm.refresh_from_db()
|
||||
return check, [str(m) for m in get_messages(response.wsgi_request)]
|
||||
|
||||
def test_making_a_template_needs_no_quota(self):
|
||||
check, _ = self.post(self.staff, is_template=True)
|
||||
self.assertTrue(self.vm.is_template)
|
||||
check.assert_not_called()
|
||||
|
||||
def test_turning_a_template_into_a_vm_is_checked(self):
|
||||
Instance.objects.filter(pk=self.vm.pk).update(is_template=True)
|
||||
check, _ = self.post(self.staff, is_template=False)
|
||||
self.assertFalse(self.vm.is_template)
|
||||
check.assert_called_once_with(self.staff, 1, 2, 2048, 10)
|
||||
|
||||
def test_over_quota_the_template_stays_a_template(self):
|
||||
Instance.objects.filter(pk=self.vm.pk).update(is_template=True)
|
||||
_, messages = self.post(self.staff, is_template=False, quota=QuotaExceeded("cpu", 9, 8))
|
||||
self.assertTrue(self.vm.is_template)
|
||||
self.assertTrue(any("quota" in m for m in messages), messages)
|
||||
|
||||
def test_an_unverifiable_quota_keeps_it_a_template(self):
|
||||
Instance.objects.filter(pk=self.vm.pk).update(is_template=True)
|
||||
_, messages = self.post(self.staff, is_template=False, quota=QUOTA_UNVERIFIED)
|
||||
self.assertTrue(self.vm.is_template)
|
||||
self.assertTrue(any("cannot be reached" in m for m in messages), messages)
|
||||
|
||||
def test_a_vm_that_is_not_shut_off_stays_as_it_is(self):
|
||||
# a running VM made a template would leave the quota while it runs
|
||||
for status in (1, 3):
|
||||
with self.subTest(status=status):
|
||||
check, messages = self.post(self.staff, is_template=True, status=status)
|
||||
self.assertFalse(self.vm.is_template)
|
||||
self.assertIn("Shut the VM down to change whether it is a template.", messages)
|
||||
|
||||
def test_superusers_have_no_quota(self):
|
||||
Instance.objects.filter(pk=self.vm.pk).update(is_template=True)
|
||||
admin = get_user_model().objects.create_superuser("tq-admin", "tq@example.com", "pw")
|
||||
check, _ = self.post(admin, is_template=False, quota=QuotaExceeded("cpu", 9, 8))
|
||||
self.assertFalse(self.vm.is_template)
|
||||
check.assert_not_called()
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
"""Console settings: each change is saved to the persistent definition, the
|
||||
password only by the action chosen for it, and the VNC settings only while
|
||||
the saved console type is VNC."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from computes.models import Compute
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.messages import get_messages
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from instances.forms import ConsoleForm
|
||||
from instances.models import Instance
|
||||
|
||||
VNC = {"console_type": "vnc", "listen_on": "127.0.0.1", "keymap": "auto", "password_action": "keep"}
|
||||
|
||||
|
||||
class UpdateConsoleTestCase(TestCase):
|
||||
def setUp(self):
|
||||
admin = get_user_model().objects.create_superuser("console_admin", "c@example.com", "pw")
|
||||
self.client.force_login(admin)
|
||||
compute = Compute.objects.create(name="console-compute", hostname="127.0.0.1", login="", password="", type=4)
|
||||
self.instance = Instance.objects.create(
|
||||
compute=compute, name="console-vm", uuid="11111111-2222-3333-4444-555555555555"
|
||||
)
|
||||
|
||||
def post(self, data, console_type="vnc"):
|
||||
"""console_type: the persistent definition's; the live one is VNC."""
|
||||
with patch("instances.models.wvmInstance") as wvm:
|
||||
proxy = wvm.return_value
|
||||
proxy.get_console_type.side_effect = lambda config=False: console_type if config else "vnc"
|
||||
proxy.get_console_listener_addr.return_value = "127.0.0.1"
|
||||
proxy.get_console_keymap.return_value = ""
|
||||
proxy.set_console_passwd.return_value = True
|
||||
response = self.client.post(reverse("instances:update_console", args=[self.instance.id]), data)
|
||||
return proxy, [str(m) for m in get_messages(response.wsgi_request)]
|
||||
|
||||
def test_console_settings_are_applied(self):
|
||||
proxy, _ = self.post({**VNC, "listen_on": "0.0.0.0", "keymap": "de"})
|
||||
proxy.set_console_keymap.assert_called_once_with("de")
|
||||
proxy.set_console_listener_addr.assert_called_once_with("0.0.0.0")
|
||||
proxy.set_console_passwd.assert_not_called()
|
||||
|
||||
def test_the_password_changes_by_the_action_chosen(self):
|
||||
for action, password in (("set", " a b c "), ("set", "éééé"), ("none", "")):
|
||||
with self.subTest(action, password=password):
|
||||
proxy, _ = self.post({**VNC, "password_action": action, "password": password})
|
||||
proxy.set_console_passwd.assert_called_once_with(password)
|
||||
proxy, _ = self.post({**VNC, "password_action": "generate"})
|
||||
(generated,), _ = proxy.set_console_passwd.call_args
|
||||
self.assertEqual(len(generated), 8)
|
||||
|
||||
def test_a_password_vnc_cannot_take_is_refused_and_nothing_is_saved(self):
|
||||
for password in ("", "123456789", "ééééé"): # libvirt counts bytes: é is two
|
||||
with self.subTest(password=password):
|
||||
proxy, messages = self.post({**VNC, "keymap": "de", "password_action": "set", "password": password})
|
||||
proxy.set_console_passwd.assert_not_called()
|
||||
proxy.set_console_keymap.assert_not_called()
|
||||
self.assertTrue(messages[0].startswith("Console settings were not saved"), messages)
|
||||
|
||||
@override_settings(SERIAL_CONSOLE_ENABLED=True)
|
||||
def test_a_new_serial_console_ignores_the_vnc_settings(self):
|
||||
# even ones that would be refused: an empty new password, an unknown keymap
|
||||
for vnc in ({"keymap": "de", "password_action": "none"},
|
||||
{"keymap": "xx", "password_action": "set", "password": ""}):
|
||||
with self.subTest(vnc):
|
||||
proxy, messages = self.post({**VNC, **vnc, "console_type": "pty"})
|
||||
self.assertEqual(messages, [])
|
||||
proxy.set_console_type.assert_called_once_with("pty")
|
||||
proxy.set_console_keymap.assert_not_called()
|
||||
proxy.set_console_passwd.assert_not_called()
|
||||
|
||||
@override_settings(SERIAL_CONSOLE_ENABLED=True)
|
||||
def test_a_saved_serial_console_shows_and_takes_no_vnc_settings(self):
|
||||
# the running VM still has VNC: the type to compare with is the saved one
|
||||
proxy, messages = self.post({"console_type": "pty"}, console_type="pty")
|
||||
self.assertEqual(messages, [])
|
||||
proxy.set_console_type.assert_not_called()
|
||||
proxy, _ = self.post({"console_type": "vnc", "keymap": "de"}, console_type="pty")
|
||||
proxy.set_console_type.assert_called_once_with("vnc")
|
||||
proxy.set_console_keymap.assert_not_called()
|
||||
with patch("instances.models.wvmInstance") as wvm:
|
||||
wvm.return_value.get_console_type.side_effect = lambda config=False: "pty" if config else "vnc"
|
||||
form = ConsoleForm(instance=Instance.objects.get(pk=self.instance.pk), is_superuser=True)
|
||||
self.assertEqual((form["console_type"].value(), sorted(form.fields)), ("pty", ["console_type"]))
|
||||
|
||||
@override_settings(SERIAL_CONSOLE_ENABLED=False)
|
||||
def test_serial_is_not_offered_while_the_serial_console_is_off(self):
|
||||
proxy, _ = self.post({**VNC, "console_type": "pty"})
|
||||
proxy.set_console_type.assert_not_called()
|
||||
Loading…
Add table
Add a link
Reference in a new issue