Merge branch 'v3' into labthings-debug-logging-config

This commit is contained in:
Beth Probert 2026-04-02 10:50:06 +01:00
commit 01954167ab
72 changed files with 2722 additions and 1046 deletions

View file

@ -0,0 +1,125 @@
# HTML Sanitisation Test Cases
This file is in markdown not Python for readability.
Each test is a level 2 heading. The input and expected values are level 3 headings. Multiline inputs and results are treated as single, multiline strings.
The HTML to test is in triple backtick code blocks.
Anything not in code blocks is a comment.
This file is run by /tests/unit_tests/test_ui.py
## Test scripts removed
The script is removed but the contents remain.
### Input
```html
<script>alert("x")</script>
<p>Hello</p>
```
### Result
```html
alert(&quot;x&quot;)
Hello
```
## Test script in bold tag
### Input
```html
<b onmouseover=alert('Wufff!')>click me!</b>
```
### Result
```html
<b>click me!</b>
```
## Test script in disallowed tag
### Input
```html
<img src="http://url.to.file.which/not.exist" onerror=alert(document.cookie);>
```
### Result
```html
```
## Test cookie grabber script is escaped
The script should become escaped and have no tags
### Input
```html
<SCRIPT type="text/javascript">
var adr = '../evil.php?cakemonster=' + escape(document.cookie);
</SCRIPT>
```
### Result
```html
var adr = &#x27;../evil.php?cakemonster=&#x27; + escape(document.cookie);
```
## Test with non-http links
Remove the javascript from the links.
### Input
```html
<a href="javascript:alert(1)">link</a>
<a href="javas&#99;ript:alert(1)">encoded</a>
<a href="mailto:spammy@spam.com">Email</a>
<a href="/a/relateive/link">Relative</a>
```
### Result
```html
<a>link</a>
<a>encoded</a>
<a>Email</a>
<a>Relative</a>
```
## Test http links become external
Remove the javascript from the links.
### Input
```html
<a href="https://openflexure.org">OpenFlexure</a>
```
### Result
```html
<a href="https://openflexure.org" target="_blank" rel="noopener noreferrer">OpenFlexure</a>
```
## Test basic formatting preserved
Check basic elements are not changed
### Input
```html
This is <b>bold</b> <i>italic</i> and on a new <br/> line.
```
### Result
```html
This is <b>bold</b> <i>italic</i> and on a new <br/> line.
```
## Test http link with path preserved
Valid HTTP links should preserve their path.
### Input
```html
<a href="https://example.com/docs/page">Docs</a>
```
### Result
```html
<a href="https://example.com/docs/page" target="_blank" rel="noopener noreferrer">Docs</a>
```
## Test http link with fragment preserved
Valid HTTP links should preserve their path and fragment.
### Input
```html
<a href="https://example.com/docs/page#section">Docs Section</a>
```
### Result
```html
<a href="https://example.com/docs/page#section" target="_blank" rel="noopener noreferrer">Docs Section</a>
```

View file

@ -73,7 +73,7 @@ def test_partial_base_classes():
bad_algo.ready
with pytest.raises(NotImplementedError):
bad_algo.settings_ui
bad_algo.settings_ui()
with pytest.raises(NotImplementedError):
bad_algo.set_background(background_image)
@ -256,7 +256,7 @@ def test_background_detect_settings_ui(detector_cls):
As both have identical settings they can be tested together
"""
detector = create_thing_without_server(detector_cls)
ui = detector.settings_ui
ui = detector.settings_ui().root
assert len(ui) == 2
assert isinstance(ui[0], PropertyControl)

View file

@ -37,7 +37,7 @@ from openflexure_microscope_server import utilities
def test_merge_patch(target, patch, outcome, err_if_enforce):
"""Check that merge_patch returns the expected outcome for each target and patch.
These test cases are specified in Appedix A of IETF RFC 7396.
These test cases are specified in Appendix A of IETF RFC 7396.
"""
assert utilities.merge_patch(target, patch) == outcome

View file

@ -167,7 +167,7 @@ def test_bad_scan_names():
scan_dir = scan_dir_manager.new_scan_dir("fake scan")
assert scan_dir.name == "fake_scan_0001"
scan_dir = scan_dir_manager.new_scan_dir("fake scan;rm -rf /;")
assert scan_dir.name == "fake_scan_rm_-rf____0001"
assert scan_dir.name == "fake_scan_rm_-rf_0001"
def test_scan_sequence_and_listing(caplog):
@ -247,6 +247,63 @@ def test_scan_sequence_and_listing(caplog):
assert len(caplog.records) == 0
def test_scan_sequence_case_sensitive():
"""Check created scans are added in order and listed correctly, even when cases are different."""
_clear_scan_dir()
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
# Create some scan data and mark it as successful to get an end date.
scan_data = fake_active_scan_data()
scan_data.set_final_data(result="Success")
# Make 4 scans
scan_dir = scan_dir_manager.new_scan_dir("fake_scan")
scan_dir.save_scan_data(scan_data)
scan_dir = scan_dir_manager.new_scan_dir("FAKE_SCAN")
scan_dir.save_scan_data(scan_data)
scan_dir = scan_dir_manager.new_scan_dir("Fake_Scan")
scan_dir.save_scan_data(scan_data)
scan_dir = scan_dir_manager.new_scan_dir("FAKE_scan")
scan_dir.save_scan_data(scan_data)
scan_dir = scan_dir_manager.new_scan_dir("fAkE_sCaN")
scan_dir.save_scan_data(scan_data)
# Check they exist and are numbered sequentially
all_scans = scan_dir_manager.all_scans
assert_unique_of_length(all_scans, 5)
assert "fake_scan_0001" in all_scans
assert "fake_scan_0002" in all_scans
assert "fake_scan_0003" in all_scans
assert "fake_scan_0004" in all_scans
assert "fake_scan_0005" in all_scans
def test_scan_names_have_single_underscores():
"""Check created scans have only one underscore in them, not two."""
_clear_scan_dir()
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
# Create some scan data and mark it as successful to get an end date.
scan_data = fake_active_scan_data()
scan_data.set_final_data(result="Success")
# Make 4 scans
scan_dir = scan_dir_manager.new_scan_dir("fake_scan_")
scan_dir.save_scan_data(scan_data)
scan_dir = scan_dir_manager.new_scan_dir("fake_scan__")
scan_dir.save_scan_data(scan_data)
scan_dir = scan_dir_manager.new_scan_dir("fake_scan_______")
scan_dir.save_scan_data(scan_data)
scan_dir = scan_dir_manager.new_scan_dir("fake_scan")
scan_dir.save_scan_data(scan_data)
# Check they exist and are numbered sequentially
all_scans = scan_dir_manager.all_scans
assert_unique_of_length(all_scans, 4)
assert "fake_scan_0001" in all_scans
assert "fake_scan_0002" in all_scans
assert "fake_scan_0003" in all_scans
assert "fake_scan_0004" in all_scans
def test_scan_name_non_sequential():
"""Check new scan has the correct name if the directories are not sequential."""
_clear_scan_dir()

View file

@ -18,7 +18,14 @@ from openflexure_microscope_server.things.scan_workflows import (
HistoScanWorkflow,
ScanWorkflow,
)
from openflexure_microscope_server.ui import PropertyControl
from openflexure_microscope_server.ui import (
Accordion,
ActionButton,
HeaderBlock,
PropertyControl,
TextBlock,
UIElementList,
)
def test_partial_base_classes():
@ -56,7 +63,7 @@ def test_partial_base_classes():
bad_workflow.acquisition_routine(settings, xyz_pos=[0, 0, 0])
with pytest.raises(NotImplementedError):
bad_workflow.settings_ui
bad_workflow.settings_ui()
@pytest.fixture
@ -370,15 +377,49 @@ def test_histo_acquisition_on_sample(histo_workflow, mocker, caplog):
assert histo_workflow._autofocus.run_smart_stack.call_count == 2
def test_histo_workflow_settings_ui(histo_workflow):
def test_histo_workflow_settings_ui(histo_workflow, mocker):
"""Check that the workflow specifies the expected controls."""
ui = histo_workflow.settings_ui
mock_prop_control = mocker.Mock(spec=PropertyControl)
histo_workflow._background_detector.settings_ui.return_value = UIElementList(
[mock_prop_control]
)
assert len(ui) == 8
for element in ui:
ui = histo_workflow.settings_ui().root
# The UI is in 4 blocks
assert len(ui) == 4
# The top is a header
assert isinstance(ui[0], HeaderBlock)
assert ui[0].text == "Histo Scan"
# The then some text (note the & is escaped)
assert isinstance(ui[1], TextBlock)
fragment = "This scan workflow is optimised for scanning H&amp;E stained biopsies."
assert fragment in ui[1].text
# Followed by a background detect accordion
assert isinstance(ui[2], Accordion)
assert ui[2].title == "Background Detect"
# And a Scan Settings accordion
assert isinstance(ui[3], Accordion)
assert ui[3].title == "Scan Settings"
# Background UI is ...
background_ui = ui[2].children.root
# what the detector specifies as it settings ...
assert background_ui[0] is mock_prop_control
# plus 2 action buttons
assert len(background_ui) == 3
assert isinstance(background_ui[1], ActionButton)
assert background_ui[1].action == "set_background"
assert isinstance(background_ui[2], ActionButton)
assert background_ui[2].action == "check_background"
# Finally check the contents of the scan settings accordion
scan_settings = ui[3].children.root
assert len(scan_settings) == 8
for element in scan_settings:
assert isinstance(element, PropertyControl)
names = [el.property_name for el in ui]
names = [el.property_name for el in scan_settings]
expected_names = [
"overlap",
"stack_images_to_save",

View file

@ -270,7 +270,9 @@ def test_create_stack(histo_scan_workflow, caplog):
initial_min_images_to_test = histo_scan_workflow.stack_min_images_to_test
initial_images_to_save = histo_scan_workflow.stack_images_to_save
with caplog.at_level(logging.INFO):
stack_params = histo_scan_workflow.create_smart_stack_params()
stack_params = histo_scan_workflow.create_smart_stack_params(
save_on_failure=not histo_scan_workflow.skip_background
)
assert len(caplog.records) == 0
assert histo_scan_workflow.stack_min_images_to_test == initial_min_images_to_test
@ -294,7 +296,9 @@ def test_coercing_stack_test_ims(
histo_scan_workflow.stack_min_images_to_test = initial_test_ims
with caplog.at_level(logging.WARNING):
stack_params = histo_scan_workflow.create_smart_stack_params()
stack_params = histo_scan_workflow.create_smart_stack_params(
save_on_failure=not histo_scan_workflow.skip_background
)
assert len(caplog.records) == 1
assert str(caplog.records[0].msg).startswith(expected_log_start)
@ -322,7 +326,9 @@ def test_coercing_stack_save_ims(
histo_scan_workflow.stack_images_to_save = initial_save_ims
with caplog.at_level(logging.WARNING):
stack_params = histo_scan_workflow.create_smart_stack_params()
stack_params = histo_scan_workflow.create_smart_stack_params(
save_on_failure=not histo_scan_workflow.skip_background
)
assert len(caplog.records) == 1
assert str(caplog.records[0].msg).startswith(expected_log_start)
@ -397,7 +403,9 @@ def setup_and_run_smart_z_stack(
is a list, it will be set as a side effect (and should be a list of tuples of
results). If it a tuple (or anything else), it is set as a return value.
"""
stack_params = histo_scan_workflow.create_smart_stack_params()
stack_params = histo_scan_workflow.create_smart_stack_params(
save_on_failure=not histo_scan_workflow.skip_background
)
stack_params.settling_time = 0 # Don't settle or tests take forever.
autofocus_thing.capture_stack_image = mocker.Mock()
@ -694,7 +702,6 @@ def test_run_basic_stack_simple(
stack_dz=10,
images_to_save=3,
settling_time=0,
backlash_correction=0,
origin=StackOrigin.START,
)
@ -757,7 +764,6 @@ def test_run_basic_stack_center_origin(
stack_dz=10,
images_to_save=5,
settling_time=0,
backlash_correction=0,
origin=StackOrigin.CENTER,
)
@ -804,97 +810,6 @@ def test_run_basic_stack_center_origin(
assert final_z == expected_final_z, "Final Z position is incorrect"
def test_run_basic_stack_backlash_applied(
autofocus_thing, mocker, fake_capture, fake_move_relative
):
"""Backlash correction should overshoot first, then move back before starting stack."""
stack_params = StackParams(
stack_dz=10,
images_to_save=3,
settling_time=0,
backlash_correction=50,
origin=StackOrigin.START,
)
capture_params = mocker.Mock()
capture_params.images_dir = "dummy"
capture_params.save_resolution = (100, 100)
start_z = 0
autofocus_thing._stage.position = {"x": 0, "y": 0, "z": start_z}
autofocus_thing.capture_stack_image = mocker.Mock(side_effect=fake_capture)
autofocus_thing._stage.move_relative = mocker.Mock(side_effect=fake_move_relative)
autofocus_thing._cam.save_from_memory = mocker.Mock()
autofocus_thing._cam.clear_buffers = mocker.Mock()
autofocus_thing.run_basic_stack(stack_params, capture_params)
calls = autofocus_thing._stage.move_relative.call_args_list
# First move is overshoot for backlash
assert calls[0].kwargs["z"] == -stack_params.backlash_correction, (
"Backlash overshoot not applied correctly"
)
# Second move corrects back to starting point
assert calls[1].kwargs["z"] == stack_params.backlash_correction, (
"Backlash correction move not applied correctly"
)
def test_run_basic_stack_backlash_and_offset(
autofocus_thing, mocker, fake_capture, fake_move_relative
):
"""Backlash correction and stack offset both applied.
Stack should begin by moving down by half the height of the stack,
plus backlash correction, then move up by backlash correction, then run the basic stack.
"""
stack_params = StackParams(
stack_dz=100,
images_to_save=3,
settling_time=0,
backlash_correction=50,
origin=StackOrigin.CENTER,
)
capture_params = mocker.Mock()
capture_params.images_dir = "dummy"
capture_params.save_resolution = (100, 100)
start_z = 0
autofocus_thing._stage.position = {"x": 0, "y": 0, "z": start_z}
autofocus_thing.capture_stack_image = mocker.Mock(side_effect=fake_capture)
autofocus_thing._stage.move_relative = mocker.Mock(side_effect=fake_move_relative)
autofocus_thing._cam.save_from_memory = mocker.Mock()
autofocus_thing._cam.clear_buffers = mocker.Mock()
autofocus_thing.run_basic_stack(stack_params, capture_params)
calls = autofocus_thing._stage.move_relative.call_args_list
# Combined initial offset + backlash overshoot
total_stack_range = stack_params.stack_dz * (stack_params.images_to_save - 1)
expected_first_move = -(total_stack_range // 2 + stack_params.backlash_correction)
assert calls[0].kwargs["z"] == expected_first_move, (
"Combined overshoot not applied correctly"
)
# Backlash correction back to base of stack
assert calls[1].kwargs["z"] == stack_params.backlash_correction, (
"Backlash correction move not applied correctly"
)
# Subsequent moves in stack
expected_stack_moves = [stack_params.stack_dz] * (stack_params.images_to_save - 1)
actual_stack_moves = [c.kwargs["z"] for c in calls[2:]]
assert actual_stack_moves == expected_stack_moves, (
"Stack moves after backlash/offset not correct"
)
def test_run_basic_stack_end_origin(
autofocus_thing, mocker, fake_capture, fake_move_relative
):
@ -903,7 +818,6 @@ def test_run_basic_stack_end_origin(
stack_dz=10,
images_to_save=4,
settling_time=0,
backlash_correction=0,
origin=StackOrigin.END,
)
@ -948,7 +862,6 @@ def test_invalid_stack_images_raises():
stack_dz=10,
images_to_save=capture_count,
settling_time=0,
backlash_correction=0,
origin=StackOrigin.START,
)
@ -960,7 +873,6 @@ def test_invalid_stack_settling_raises():
stack_dz=10,
images_to_save=1,
settling_time=-1,
backlash_correction=0,
origin=StackOrigin.START,
)

View file

@ -16,11 +16,13 @@ from pydantic import BaseModel
import labthings_fastapi as lt
from openflexure_microscope_server.stitching import (
FORBIDDEN_COMMANDS,
BaseStitcher,
FinalStitcher,
PreviewStitcher,
StitcherValidationError,
StitchingSettings,
validate_command,
)
from ..shared_utils.lt_test_utils import LabThingsTestEnv
@ -32,6 +34,29 @@ THIS_DIR: str = os.path.dirname(os.path.realpath(__file__))
MOCK_STITCHER: str = os.path.join(THIS_DIR, "mock_stitching", "mock-stitch.py")
def test_validate_command_success():
"""Test valid commands pass validation."""
validate_command(["openflexure-stitch", "--stitching_mode", "all", "path/to/scan"])
validate_command(["--resize", "0.5", "8192"])
@pytest.mark.parametrize("cmd_name", FORBIDDEN_COMMANDS)
def test_validate_command_forbidden(cmd_name):
"""Test forbidden commands raise error."""
# As the main command
with pytest.raises(
StitcherValidationError, match=f"Forbidden element '{cmd_name}' detected"
):
validate_command([cmd_name, "some_arg"])
# As an argument (case-insensitive)
with pytest.raises(
StitcherValidationError,
match=f"Forbidden element '{cmd_name.upper()}' detected",
):
validate_command(["safe-command", cmd_name.upper()])
def test_base_stitcher():
"""Test the logic in BaseStitcher.

View file

@ -0,0 +1,24 @@
"""Testing the server specified user interface."""
import os
import pytest
from openflexure_microscope_server import ui
from ..shared_utils.md_test_cases import find_test_cases
THIS_DIR = os.path.dirname(os.path.abspath(__file__))
HTML_SANITISATION_TEST_FILE = os.path.join(
THIS_DIR, "data", "html_sanitisation_test_cases.md"
)
HTML_SANITISATION_TEST_CASES = find_test_cases(HTML_SANITISATION_TEST_FILE, "html")
@pytest.mark.parametrize("test_case", HTML_SANITISATION_TEST_CASES)
def test_html_block_sanitises(test_case):
"""Check each case in the test case file sanitises as expected."""
sanitised = ui.sanitise_html(test_case.input_code).strip()
assert sanitised == test_case.result_code, (
f"Test case: '{test_case.title}': Input code doesn't sanitise as expected."
)

View file

@ -0,0 +1,169 @@
"""Unit tests for utility functions."""
import logging
import sys
import pytest
from openflexure_microscope_server.utilities import (
_WINDOWS_RESERVED_NAMES,
is_path_safe,
make_name_safe,
)
PATHS_WITH_DOTS = {"a./b/c.", "path./to/file.", "./path./to/file."}
PATHS_WITH_SPACES = {"path /to /file ", "path/to /file", "path/a long path name /file"}
RESERVED_NAME_PATHS = {"path/CON/file", "path/NUL.txt/file", "C:/AUX/test"}
if sys.platform.startswith("win"):
UNSAFE_CHARACTER_PATHS = {
"C:/path/asterisk/data*.csv",
"data/files/read_me_first!.txt",
"logs/system<error>.log",
"projects/alpha#1/notes.txt",
}
else:
UNSAFE_CHARACTER_PATHS = {
"/var/log/app:backup.log",
"/etc/configs/network-settings\v1",
"/tmp/file_with_@_symbol.txt",
"/home/user/script(1).py",
}
def test_make_name_safe_basic():
"""Test basic functionality of make_name_safe."""
assert make_name_safe("normal_name") == "normal_name"
assert make_name_safe("name with spaces") == "name_with_spaces"
assert make_name_safe("name/with/slashes") == "name_with_slashes"
assert make_name_safe("name\\with\\backslashes") == "name_with_backslashes"
assert make_name_safe("sPoNgEmoCk") == "spongemock"
def test_make_name_safe_trailing_chars():
"""Test that trailing dots and spaces are removed."""
assert make_name_safe("name.") == "name"
assert make_name_safe("name ") == "name"
assert make_name_safe("name. ") == "name"
assert make_name_safe("name .") == "name"
assert make_name_safe(".") == "_"
assert make_name_safe(" ") == "_"
assert make_name_safe(". ") == "_"
@pytest.mark.parametrize("name", _WINDOWS_RESERVED_NAMES)
def test_make_name_safe_reserved_names(name):
"""Test Windows reserved names."""
# Base name should be sanitized
assert make_name_safe(name) == f"{name.lower()}_"
# Case-insensitive
assert make_name_safe(name.lower()) == f"{name.lower()}_"
# With extension
assert make_name_safe(f"{name}.txt") == f"{name.lower()}.txt_"
# Multiple extensions
assert make_name_safe(f"{name}.tar.gz") == f"{name.lower()}.tar.gz_"
def test_make_name_safe_reserved_names_false_positives():
"""Test that names containing but not equal to reserved names are safe."""
assert make_name_safe("CONSTANT") == "constant"
assert make_name_safe("CON2") == "con2"
assert make_name_safe("ICON") == "icon"
assert make_name_safe("icon") == "icon"
assert make_name_safe("iCon") == "icon"
assert make_name_safe("iCOn") == "icon"
assert make_name_safe("icOn") == "icon"
assert make_name_safe("CHILLI CON CARNE") == "chilli_con_carne"
def test_is_path_safe_basic(caplog):
"""Test basic functionality of is_path_safe."""
# Note: behavior depends on platform for separators
with caplog.at_level(logging.WARNING):
if sys.platform.startswith("win"):
assert is_path_safe("C:\\path\\to/file")
assert is_path_safe("C:\\a very long path\\to/file")
else:
assert is_path_safe("path/to/file")
assert is_path_safe("a very long path/to/file")
# No errors should be raised. These paths are safe.
assert len(caplog.records) == 0
@pytest.mark.parametrize("path", UNSAFE_CHARACTER_PATHS)
def test_is_path_safe_unsafe_characters_in_components(caplog, path):
"""Test unsafe characters within path components."""
with caplog.at_level(logging.WARNING):
assert not is_path_safe(path)
assert (
f"{path} contains characters that may be unsafe on this platform."
in caplog.messages
)
@pytest.mark.parametrize("path", RESERVED_NAME_PATHS)
def test_is_path_safe_reserved_in_components(caplog, path):
"""Test reserved names within path components."""
with caplog.at_level(logging.WARNING):
assert not is_path_safe(path)
assert (
f"{path} contains a reserved system name and may cause issues."
in caplog.messages
)
def test_is_path_safe_reserved_name_components(caplog):
"""Test reserved names are okay as part of a larger dir name."""
with caplog.at_level(logging.WARNING):
assert is_path_safe("chilli con carne/recipe")
assert len(caplog.records) == 0
@pytest.mark.parametrize("path", PATHS_WITH_SPACES)
def test_is_path_safe_trailing_space_in_components(caplog, path):
"""Test trailing spaces in path components."""
with caplog.at_level(logging.WARNING):
# Test Windows - cannot have trailing spaces
if sys.platform.startswith("win"):
assert not is_path_safe(path)
assert len(caplog.records) == 1
assert f"{path} contains unsafe trailing spaces." in caplog.messages
else:
# Trailing spaces allows in POSIX systems
assert is_path_safe(path)
# No warnings should be raised
assert len(caplog.records) == 0
def test_is_path_safe_relative(caplog):
"""Test that relative path components are preserved."""
# Relative imports ./foo, ../foo or ../../foo are allowed.
with caplog.at_level(logging.WARNING):
assert is_path_safe("./openflexure/data/")
assert is_path_safe("../openflexure/data/")
assert is_path_safe("../../openflexure/data/")
assert is_path_safe(".")
assert is_path_safe("..")
assert len(caplog.records) == 0
assert not is_path_safe("a_bad/../relative_path")
assert len(caplog.records) == 1
assert (
"File path a_bad/../relative_path may be unsafe due to unexpected relative navigation."
in caplog.messages
)
@pytest.mark.parametrize("filepath", PATHS_WITH_DOTS)
def test_is_path_safe_dots_warning(caplog, filepath):
"""Test that a user is warned about trailing dots in their file paths. The paths should remain unchanged."""
with caplog.at_level(logging.WARNING):
assert not is_path_safe(filepath)
assert (
f"File path {filepath} may be unsafe due to trailing dots."
in caplog.messages
)

View file

@ -19,7 +19,7 @@ THIS_DIR = os.path.dirname(__file__)
TRUE_REPO_DIR = os.path.dirname(os.path.dirname(THIS_DIR))
# For explicit version checking.
VER_STRING = "3.0.0-alpha4"
VER_STRING = "3.0.0-alpha5"
# This is the regex provided by https://semver.org/
SEMVER_REGEX = re.compile(