1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 19:26:48 +00:00
webvirtcloud/instances/tests/test_create_form_input.py
catborise ab5ed59ea1 feat(instances): a new VM is made on one form in three steps
The create page had three tabs (flavor, custom, template), each with its
own copy of the same create form; every flavor rendered one more copy in
a dialog, and fixes had to be made in each copy. All of them posted the
same form to the same job, differing only in where the disk comes from.

The page is now one form in sections: name and size, disk, networks and
an Advanced section. The disk section offers three sources as radio
cards (a new disk, existing volumes, a copy of a template); only the
chosen source's fields are shown, and the others are disabled, so they
are neither sent nor validated. A template's copy goes into the
template's pool unless another is picked. Flavors are presets that fill
vCPU, memory and disk size and pick a new disk; they are added and
deleted in a dialog outside the form. Every source uses the
per-interface network rows, so a new disk no longer requires a MAC:
libvirt gives an interface without one its address, as it did for the
other sources. A refused form comes back with its source, volumes,
template and networks, and the focus on its first error. Without a
storage pool or a network the page shows why instead of the form, and a
host that cannot be read is a message, not a 500.

The host dialog and the machine page share the form's look: hosts are
rows with their CPU and memory use, the machine page is a card with
Cancel/Next and the XML way is a collapsible section. All three steps
show the same step indicator, done steps linking back.

The create job refuses more than one disk source (the page sends one; a
crafted request could send two, and the job would have used the first).
A field with help text is described by its help and its error.

Frontend review fixes: the presets and the disclosure buttons show a
focus ring for keyboard users; a disclosure spans its card and its
chevron turns while open; a preset says it picks a new disk; numeric
fields bring a numeric keyboard and MAC fields no autocorrection; the
machine page's Next waits for a machine type. Tom Select's control
keeps the text colour of the select it replaces: its own fixed colour
made typed text invisible on dark Bootswatch themes, on every page that
uses it.
2026-10-23 23:26:28 +03:00

216 lines
12 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
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_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_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 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 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))