"""A refused VM create form comes back bound, with what was entered and the disk source that was chosen; flavors are presets, not forms.""" import json import uuid from unittest.mock import patch import libvirt from computes.models import Compute from django.contrib.auth import get_user_model from django.test import TestCase from django.urls import reverse from instances.models import Flavor from instances.tests.create_page import render_create_page from lxml import html from operations import claims, jobs from operations.models import Operation from vrtManager import util def valid(**fields): """A create submission NewVMForm accepts.""" return {"name": "vm1", "firmware": "BIOS", "vcpu": "1", "memory": "512", "networks": "default", "cache_mode": "default", "add_cdrom": "None", "add_input": "None", "video": "vga", "listener_addr": "0.0.0.0", "submission_key": uuid.uuid4().hex, **fields} class CreateFormInputTests(TestCase): def test_the_page_has_one_unbound_form(self): _, response = render_create_page(self) self.assertFalse(response.context["vm_form"].is_bound) self.assertFalse(response.context["refused"]) page = html.fromstring(response.content) self.assertEqual(len(page.xpath("//form[.//button[@name='create']]")), 1) def test_the_refused_form_comes_back_bound(self): _, response = render_create_page(self, valid(name="bad name!", vcpu="2")) self.assertTrue(response.context["refused"]) self.assertTrue(response.context["vm_form"].is_bound) self.assertIn("name", response.context["vm_form"].errors) def test_a_refusal_after_validation_keeps_the_form(self): for refusal in (claims.Busy("compute 1"), jobs.SubmissionConflict("already submitted"), util.OperationError("A virtual machine with this name already exists")): with self.subTest(refusal=type(refusal).__name__), \ patch("instances.views.jobs.submit", side_effect=refusal): _, response = render_create_page(self, valid(disk_source="template")) self.assertEqual(response.status_code, 200) self.assertEqual(response.context["disk_source"], "template") self.assertTrue(response.context["vm_form"].is_bound) self.assertTrue(list(response.context["messages"])) def test_a_new_disk_without_a_mac_is_queued(self): # as for the other sources, libvirt gives the interface its MAC _, response = render_create_page(self, valid(hdd_size="10", storage="default-pool", mac="")) self.assertEqual(response.status_code, 302) self.assertEqual(Operation.objects.get().kind, "instance.create") def test_no_form_without_a_storage_pool(self): with patch("vrtManager.connection.wvmConnect.get_storages", return_value=[]): _, response = render_create_page(self) self.assertIsNone(response.context["vm_form"]) self.assertEqual(html.fromstring(response.content).xpath("//button[@name='create']"), []) self.assertTrue(any("storage pools" in str(m) for m in response.context["messages"])) def test_a_host_that_cannot_be_read_is_a_message(self): import libvirt with patch("instances.views.wvmCreate", side_effect=libvirt.libvirtError("unreachable")): _, response = render_create_page(self) self.assertEqual(response.status_code, 200) self.assertTrue(any("unreachable" in str(m) for m in response.context["messages"])) def test_a_valid_post_queues_the_job(self): compute, response = render_create_page(self, valid( disk_source="new", hdd_size="10", storage="default-pool", mac="52:54:00:00:00:01")) self.assertRedirects(response, reverse("instances", args=[compute.id]), fetch_redirect_response=False) self.assertEqual(Operation.objects.get().kind, "instance.create") def test_the_cloud_init_seed_is_chosen_on_the_form(self): _, response = render_create_page(self) (box,) = html.fromstring(response.content).xpath("//input[@name='cloud_init']") self.assertIsNone(box.get("checked")) # the test driver has no CD-ROM bus to pass the job's checks: what the form hands over queued = Operation(pk=1, params={"machine": "pc"}) with patch("instances.views.jobs.submit", return_value=queued) as submit: render_create_page(self, valid(disk_source="new", hdd_size="10", storage="default-pool", cloud_init="true")) self.assertIs(submit.call_args.args[2]["cloud_init"], True) def test_two_disk_sources_are_refused(self): # the page sends only the chosen source; a crafted request with two queues nothing _, response = render_create_page(self, valid( hdd_size="10", template="base.img", storage="default-pool", mac="52:54:00:00:00:01")) self.assertEqual(response.status_code, 200) self.assertTrue(any("Choose one disk source" in str(m) for m in response.context["messages"])) self.assertFalse(Operation.objects.exists()) def test_impossible_sizes_are_refused_beside_their_fields(self): _, response = render_create_page(self, valid(vcpu="0", memory="-1", hdd_size="0", storage="default-pool")) self.assertEqual(response.status_code, 200) page = html.fromstring(response.content) for field in ("vm_vcpu", "vm_memory", "vm_hdd_size"): with self.subTest(field=field): self.assertIn("greater than or equal to 1", page.get_element_by_id(f"{field}_error").text_content()) self.assertFalse(Operation.objects.exists()) def test_a_bad_mac_is_refused_by_the_form(self): _, response = render_create_page(self, valid(hdd_size="10", storage="default-pool", networks="default,isolated", mac="52:54:00:00:00:01,aa:")) self.assertEqual(response.status_code, 200) self.assertIn("Invalid MAC address", " ".join(response.context["vm_form"].errors.get("mac", []))) self.assertFalse(Operation.objects.exists()) def test_only_the_chosen_disk_source_is_enabled(self): for posted, chosen in (("template", "template"), ("existing", "existing"), ("nonsense", "new")): with self.subTest(posted=posted): _, response = render_create_page(self, valid(name="bad name!", disk_source=posted)) page = html.fromstring(response.content) self.assertEqual(page.xpath("//input[@name='disk_source'][@checked]/@value"), [chosen]) for source in ("new", "existing", "template"): fieldset = page.get_element_by_id(f"source-{source}") off = source != chosen self.assertEqual((fieldset.get("disabled") is not None, fieldset.get("hidden") is not None), (off, off), source) class CreateFormValuesTests(TestCase): """The form renders the submitted values and errors.""" def page(self, **fields): _, response = render_create_page(self, valid(**fields)) self.assertEqual(response.status_code, 200) return html.fromstring(response.content) def summary(self, page): return " ".join(page.xpath("//form[@id='create-vm-form']//*[contains(@class, 'form-errors')]//text()")) def test_the_form_keeps_what_was_submitted(self): page = self.page(name="bad name!", vcpu="3", memory="2048", cache_mode="writeback") self.assertEqual(page.get_element_by_id("vm_name").get("value"), "bad name!") self.assertEqual(page.get_element_by_id("vm_vcpu").get("value"), "3") self.assertEqual(page.get_element_by_id("vm_memory").get("value"), "2048") self.assertEqual(page.xpath("//select[@id='vm_cache_mode']/option[@selected]/@value"), ["writeback"]) self.assertIsNone(page.get_element_by_id("vm_qemu_ga").get("checked")) # not submitted: unchecked def test_a_new_form_has_its_defaults(self): _, response = render_create_page(self) page = html.fromstring(response.content) self.assertEqual(page.get_element_by_id("vm_vcpu").get("value"), "1") self.assertIsNotNone(page.get_element_by_id("vm_qemu_ga").get("checked")) def test_a_blank_number_stays_blank(self): page = self.page(vcpu="") self.assertEqual(page.get_element_by_id("vm_vcpu").get("value"), "") self.assertTrue(page.get_element_by_id("vm_vcpu_error").text_content().strip()) def test_the_name_is_escaped(self): page = self.page(name='x"') self.assertEqual(page.get_element_by_id("vm_name").get("value"), 'x"') self.assertEqual(page.xpath("//form[@id='create-vm-form']//b"), []) def test_the_error_sits_beside_its_field(self): page = self.page(name="bad name!") field = page.get_element_by_id("vm_name") self.assertIn("is-invalid", field.get("class").split()) self.assertEqual(field.get("aria-invalid"), "true") # described by its help and its error, in one attribute self.assertEqual(field.get("aria-describedby").split(), ["vm_name_help", "vm_name_error"]) self.assertIn("special characters", page.get_element_by_id("vm_name_error").text_content()) # and in the summary at the top of the form self.assertIn("Name: The name of the virtual machine", self.summary(page)) def test_the_template_source_keeps_its_pool_template_and_storage(self): page = self.page(name="bad name!", disk_source="template", template_pool="default-pool", template="base.img", storage="default-pool") form = page.get_element_by_id("create-vm-form") self.assertEqual((form.get("data-template-pool"), form.get("data-template")), ("default-pool", "base.img")) self.assertEqual(page.xpath("//select[@id='template_storage']/option[@selected]/@value"), ["default-pool"]) def test_an_error_of_a_field_the_form_hides_is_in_its_summary(self): # the networks are a hidden input behind a picker page = self.page(networks="") self.assertIn("Networks: This field is required.", self.summary(page)) def test_a_firmware_error_without_a_firmware_choice_is_in_the_summary(self): # an architecture with no firmware label renders no firmware select with patch("vrtManager.connection.wvmConnect.label_for_firmware_path", return_value=None): fields = valid() del fields["firmware"] _, response = render_create_page(self, fields) page = html.fromstring(response.content) self.assertEqual(page.xpath("//select[@name='firmware']"), []) self.assertIn("Firmware: This field is required.", self.summary(page)) class FlavorPresetTests(TestCase): """Flavors are presets of the one form; they are managed outside it.""" def setUp(self): self.flavor = Flavor.objects.create(label="wvc-test", memory=512, vcpu=1, disk=10) def test_a_flavor_is_a_preset_button(self): _, response = render_create_page(self) page = html.fromstring(response.content) [preset] = page.xpath(f"//button[contains(@class, 'preset')][@data-label='{self.flavor.label}']") self.assertEqual((preset.get("data-vcpu"), preset.get("data-memory"), preset.get("data-disk")), ("1", "512", "10")) self.assertEqual((preset.get("type"), preset.get("aria-pressed")), ("button", "false")) def test_the_flavor_dialog_sits_outside_the_form(self): _, response = render_create_page(self) page = html.fromstring(response.content) self.assertEqual(page.xpath("//form[@id='create-vm-form']//*[@id='flavors-dialog']"), []) actions = page.get_element_by_id("flavors-dialog").xpath(".//form/@action") self.assertIn(reverse("instances:flavor_delete", args=[self.flavor.id]), actions) self.assertEqual(actions[-1], reverse("instances:flavor_create")) class InPageFlavorTests(TestCase): """The create page's flavor forms are sent from the page and get back its presets and flavor dialog, so the unfinished VM form stays.""" def setUp(self): self.flavor = Flavor.objects.create(label="wvc-test", memory=512, vcpu=1, disk=10) render_create_page(self) # logs in a superuser def post(self, url, data=None): response = self.client.post(url, data or {}, HTTP_X_REQUESTED_WITH="XMLHttpRequest") self.assertEqual(response.status_code, 200) self.assertEqual(list(response.context["messages"]), []) return html.fromstring(response.content) def test_an_added_flavor_is_a_preset_and_a_dialog_row(self): reply = self.post(reverse("instances:flavor_create"), {"label": "big", "memory": 4096, "vcpu": 4, "disk": 40}) self.assertEqual(reply.xpath("//*[@id='presets']//button/@data-label")[-2:], ["wvc-test", "big"]) self.assertEqual(len(reply.xpath("//*[@id='flavors']//li")), Flavor.objects.count()) self.assertEqual(reply.xpath("//*[@id='flavors']//input[@name='label']/@value"), []) def test_a_refused_flavor_shows_its_errors_in_the_dialog(self): reply = self.post(reverse("instances:flavor_create"), {"label": "big", "memory": "", "vcpu": 4, "disk": 40}) self.assertFalse(Flavor.objects.filter(label="big").exists()) self.assertEqual(reply.xpath("//*[@id='flavors']//input[@name='label']/@value"), ["big"]) self.assertTrue(reply.xpath("//*[@id='flavors']//input[@name='memory'][contains(@class, 'is-invalid')]")) def test_a_deleted_flavor_leaves_the_presets(self): reply = self.post(reverse("instances:flavor_delete", args=[self.flavor.id])) self.assertNotIn("wvc-test", reply.xpath("//*[@id='presets']//button/@data-label")) self.assertNotIn("wvc-test", reply.xpath("//*[@id='flavors']//li//strong/text()")) class SubmittedListsTests(TestCase): """The volumes and networks of a refused form come back with it.""" def lists(self, response): """The volumes and networks the page restores, or None.""" element = html.fromstring(response.content).get_element_by_id("submitted-lists", None) return None if element is None else json.loads(element.text) def test_a_refused_form_sends_back_its_lists(self): _, response = render_create_page(self, valid( name="bad name!", disk_source="existing", **{"storage-control": "default"}, images="a.qcow2,b.qcow2", device0="cdrom", bus0="sata", device1="disk", bus1="virtio", networks="default,isolated", mac="52:54:00:00:00:01")) self.assertEqual(self.lists(response), { "pool": "default", "disks": [{"volume": "a.qcow2", "device": "cdrom", "bus": "sata"}, {"volume": "b.qcow2", "device": "disk", "bus": "virtio"}], "networks": [{"name": "default", "mac": "52:54:00:00:00:01"}, {"name": "isolated", "mac": ""}], }) def test_a_hostile_volume_name_round_trips_through_the_lists(self): name = "x'\">" _, response = render_create_page(self, valid(name="bad name!", disk_source="existing", images=name)) self.assertEqual(self.lists(response)["disks"][0]["volume"], name) self.assertNotIn(b"", response.content) def test_only_a_refused_form_sends_lists(self): _, response = render_create_page(self) self.assertIsNone(self.lists(response)) class XmlRefusalTests(TestCase): """A refused XML definition comes back in its open editor, its reason beside it.""" def post(self, xml): conn = libvirt.open("test:///default") self.addCleanup(conn.close) compute, _ = Compute.objects.get_or_create(name="testdrv", hostname="localhost", login="", password="", type=4) self.client.force_login(get_user_model().objects.create_superuser("xml", "xml@example.com", "pw")) with patch("vrtManager.connection.connection_manager.get_connection", return_value=conn): response = self.client.post(reverse("instances:create_instance_select_type", args=[compute.id]), {"create_xml": "", "dom_xml": xml}) self.assertEqual(response.status_code, 200) page = html.fromstring(response.content) self.assertIn("show", page.get_element_by_id("from-xml").classes) self.assertEqual(page.xpath("//*[@id='editor']/text()"), [xml]) self.assertEqual(page.xpath("//input[@name='dom_xml']/@value"), [xml]) return page.xpath("//*[@id='from-xml']//*[contains(@class, 'form-errors')]")[0].text_content() def test_a_taken_name(self): self.assertIn("already exists", self.post("test")) def test_a_definition_without_a_name(self): self.assertIn("no name", self.post("1024")) def test_malformed_xml(self): self.assertIn("not well-formed", self.post("new")) def test_a_definition_libvirt_refuses(self): self.assertIn("libvirt", self.post("new1024"))