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 999480301e fix(instances): a new VM's vCPUs, memory and disk size are at least 1
The create form and the API took 0 or a negative number for vCPUs,
memory and a new disk's size: the API queued the job (202) and it
failed later in libvirt; the page did the same, its formnovalidate
skipping the browser's min. The create model's fields now refuse
values below 1, so the form shows the error beside its field and the
API answers 400 naming it.

The form also checks the MAC addresses as the API and the job do (one
per NIC at most, each a MAC address), so a bad one is an error on the
form instead of a job that fails.

Closes #175
2026-10-24 09:31:12 +03:00

232 lines
13 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_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 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))