1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 11:16:47 +00:00
webvirtcloud/instances/tests/test_create_form_input.py
catborise 005fa59bf0 feat(instances): a new VM can have UEFI Secure Boot and a TPM 2.0
Windows 11 does not install without Secure Boot and a TPM 2.0, and the new
VM form offered neither: its firmware list was BIOS and one UEFI loader
guessed from file names, which on RHEL never matched the Secure Boot one.

The firmware choices are now BIOS, UEFI and UEFI Secure Boot, as the
host's libvirt reports them (its firmware features from libvirt 12.1,
before that its loaders), and libvirt picks the firmware itself: UEFI
without Secure Boot, or with Microsoft's keys enrolled. A TPM 2.0 switch,
shown when the host has swtpm, adds an emulated TPM. The create job
refuses a firmware or a TPM the host does not offer, and the API takes
both. The path-guessing helpers are gone.
2026-11-01 16:18:29 +03:00

318 lines
17 KiB
Python

"""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", "vcpu_mode": "host-model", "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='<b>x</b>"')
self.assertEqual(page.get_element_by_id("vm_name").get("value"), '<b>x</b>"')
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 choice renders no firmware select
with patch("vrtManager.connection.wvmConnect.get_firmwares", return_value=[]):
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'\"><img src=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"<img src=x>", 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("<domain type='test'><name>test</name></domain>"))
def test_a_definition_without_a_name(self):
self.assertIn("no name", self.post("<domain type='kvm'><memory>1024</memory></domain>"))
def test_malformed_xml(self):
self.assertIn("not well-formed", self.post("<domain type='test'><name>new</name>"))
def test_a_definition_libvirt_refuses(self):
self.assertIn("libvirt", self.post("<domain type='nope'><name>new</name><memory>1024</memory></domain>"))
class FlavorFormTests(TestCase):
def test_a_flavor_is_at_least_one_of_each_in_named_units(self):
from instances.forms import FlavorForm
form = FlavorForm({"label": "tiny", "memory": "0", "vcpu": "-1", "disk": "0"})
self.assertEqual(set(form.errors), {"memory", "vcpu", "disk"})
self.assertEqual((form["memory"].label, form["disk"].label), ("Memory (MiB)", "Disk (GiB)"))