|
1 | 1 | # pylint:disable=redefined-outer-name |
2 | 2 |
|
| 3 | +from pathlib import Path |
3 | 4 | from unittest.mock import AsyncMock |
4 | 5 |
|
5 | 6 | import pytest |
|
10 | 11 | FileNotificationEventType, |
11 | 12 | FileNotificationMessage, |
12 | 13 | ) |
| 14 | +from models_library.services_types import ServiceRunID |
13 | 15 | from models_library.users import UserID |
14 | 16 | from pytest_mock import MockerFixture |
15 | 17 | from simcore_service_dynamic_sidecar.modules.file_notification_subscriber import ( |
16 | 18 | _handle_file_notification, |
| 19 | + _resolve_local_path_from_storage_id, |
17 | 20 | ) |
| 21 | +from simcore_service_dynamic_sidecar.modules.mounted_fs import MountedVolumes |
18 | 22 |
|
19 | 23 |
|
20 | 24 | @pytest.fixture() |
@@ -94,3 +98,105 @@ async def test_handle_file_notification_with_optional_ids( |
94 | 98 | mock_notify_path_change.assert_awaited_once_with( |
95 | 99 | app=None, event_type=FileNotificationEventType.FILE_UPLOADED, path=file_id, recursive=False |
96 | 100 | ) |
| 101 | + |
| 102 | + |
| 103 | +_INPUTS_PATH = Path("/inputs") |
| 104 | +_OUTPUTS_PATH = Path("/outputs") |
| 105 | +_STATE_PATH_A = Path("/workspace/state-a") |
| 106 | +_STATE_PATH_B = Path("/workspace/state-b") |
| 107 | + |
| 108 | + |
| 109 | +@pytest.fixture() |
| 110 | +def mounted_volumes(tmp_path: Path, faker: Faker, node_id: NodeID) -> MountedVolumes: |
| 111 | + return MountedVolumes( |
| 112 | + service_run_id=ServiceRunID(faker.uuid4()), |
| 113 | + node_id=node_id, |
| 114 | + inputs_path=_INPUTS_PATH, |
| 115 | + outputs_path=_OUTPUTS_PATH, |
| 116 | + user_preferences_path=None, |
| 117 | + state_paths=[_STATE_PATH_A, _STATE_PATH_B], |
| 118 | + state_exclude=set(), |
| 119 | + compose_namespace="test-namespace", |
| 120 | + dy_volumes=tmp_path, |
| 121 | + ) |
| 122 | + |
| 123 | + |
| 124 | +def _make_storage_id(project_id: ProjectID, node_id: NodeID, volume_name: str, *parts: str) -> str: |
| 125 | + return "/".join((f"{project_id}", f"{node_id}", volume_name, *parts)) |
| 126 | + |
| 127 | + |
| 128 | +@pytest.mark.parametrize( |
| 129 | + "state_path_index, sub_parts", |
| 130 | + [ |
| 131 | + pytest.param(0, ("sub", "file.bin"), id="first-state-volume-with-subdir"), |
| 132 | + pytest.param(1, ("file.bin",), id="second-state-volume-flat"), |
| 133 | + ], |
| 134 | +) |
| 135 | +def test_resolve_local_path_for_state_volume_resolves_correctly( |
| 136 | + mounted_volumes: MountedVolumes, |
| 137 | + project_id: ProjectID, |
| 138 | + node_id: NodeID, |
| 139 | + state_path_index: int, |
| 140 | + sub_parts: tuple[str, ...], |
| 141 | +): |
| 142 | + state_paths = [_STATE_PATH_A, _STATE_PATH_B] |
| 143 | + state_disk_paths = list(mounted_volumes.disk_state_paths_iter()) |
| 144 | + storage_id = _make_storage_id(project_id, node_id, state_paths[state_path_index].name, *sub_parts) |
| 145 | + |
| 146 | + resolved = _resolve_local_path_from_storage_id(mounted_volumes, storage_id) |
| 147 | + |
| 148 | + assert resolved is not None |
| 149 | + expected = state_disk_paths[state_path_index].joinpath(*sub_parts).resolve() |
| 150 | + assert resolved == expected |
| 151 | + |
| 152 | + |
| 153 | +@pytest.mark.parametrize( |
| 154 | + "volume_path", |
| 155 | + [ |
| 156 | + pytest.param(_INPUTS_PATH, id="inputs"), |
| 157 | + pytest.param(_OUTPUTS_PATH, id="outputs"), |
| 158 | + ], |
| 159 | +) |
| 160 | +def test_resolve_local_path_for_inputs_and_outputs_returns_none( |
| 161 | + mounted_volumes: MountedVolumes, |
| 162 | + project_id: ProjectID, |
| 163 | + node_id: NodeID, |
| 164 | + volume_path: Path, |
| 165 | +): |
| 166 | + """Regression: inputs/outputs volumes MUST NOT be resolved by the bind-mount fallback. |
| 167 | +
|
| 168 | + Resolving outputs would cause the dynamic-sidecar to download (via the file-notification |
| 169 | + fallback) the file it just uploaded, into the very directory the outputs watcher is |
| 170 | + observing — re-triggering the upload and producing an infinite loop. |
| 171 | + """ |
| 172 | + storage_id = _make_storage_id(project_id, node_id, volume_path.name, "some-file.txt") |
| 173 | + |
| 174 | + assert _resolve_local_path_from_storage_id(mounted_volumes, storage_id) is None |
| 175 | + |
| 176 | + |
| 177 | +@pytest.mark.parametrize( |
| 178 | + "storage_id_template", |
| 179 | + [ |
| 180 | + pytest.param( |
| 181 | + "{project_id}/{node_id}/not-a-volume/file.bin", |
| 182 | + id="unknown-volume", |
| 183 | + ), |
| 184 | + pytest.param( |
| 185 | + "only/two", |
| 186 | + id="too-few-parts", |
| 187 | + ), |
| 188 | + pytest.param( |
| 189 | + f"{{project_id}}/{{node_id}}/{_STATE_PATH_A.name}/../../etc/passwd", |
| 190 | + id="path-traversal", |
| 191 | + ), |
| 192 | + ], |
| 193 | +) |
| 194 | +def test_resolve_local_path_returns_none_on_invalid_storage_ids( |
| 195 | + mounted_volumes: MountedVolumes, |
| 196 | + project_id: ProjectID, |
| 197 | + node_id: NodeID, |
| 198 | + storage_id_template: str, |
| 199 | +): |
| 200 | + storage_id = storage_id_template.format(project_id=project_id, node_id=node_id) |
| 201 | + |
| 202 | + assert _resolve_local_path_from_storage_id(mounted_volumes, storage_id) is None |
0 commit comments