|
11 | 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
12 | 12 | # See the License for the specific language governing permissions and |
13 | 13 | # limitations under the License. |
| 14 | +import io |
| 15 | +import json |
| 16 | +from uuid import uuid4 |
| 17 | +from zipfile import ZipFile |
| 18 | + |
14 | 19 | import pytest |
15 | 20 |
|
| 21 | +from nvflare.dashboard.application import db |
16 | 22 | from nvflare.dashboard.application.constants import FLARE_DASHBOARD_NAMESPACE |
| 23 | +from nvflare.dashboard.application.models import Client, Project |
| 24 | +from nvflare.dashboard.application.store import Store |
17 | 25 |
|
18 | 26 | CLIENT1 = {"name": "site-1", "organization": "test.com", "capacity": {"num_gpus": 16, "mem_per_gpu_in_GiB": 64}} |
19 | 27 |
|
@@ -69,3 +77,140 @@ def test_update_client(self, client, client_ids, auth_header): |
69 | 77 |
|
70 | 78 | assert response.status_code == 200 |
71 | 79 | assert response.json["client"]["organization"] == NEW_ORG |
| 80 | + |
| 81 | + |
| 82 | +class TestClientPropsRejection: |
| 83 | + @pytest.fixture(scope="class") |
| 84 | + @classmethod |
| 85 | + def creator_header(cls, client, auth_header): |
| 86 | + email = f"client-creator-{uuid4().hex}@test.com" |
| 87 | + response = client.post( |
| 88 | + FLARE_DASHBOARD_NAMESPACE + "/api/v1/users", |
| 89 | + json={"email": email, "password": "test-password", "organization": "test.com", "role": "member"}, |
| 90 | + ) |
| 91 | + assert response.status_code == 201 |
| 92 | + user_id = response.json["user"]["id"] |
| 93 | + response = client.post( |
| 94 | + FLARE_DASHBOARD_NAMESPACE + "/api/v1/login", json={"email": email, "password": "test-password"} |
| 95 | + ) |
| 96 | + assert response.status_code == 200 |
| 97 | + yield {"Authorization": "Bearer " + response.json["access_token"]} |
| 98 | + response = client.delete(FLARE_DASHBOARD_NAMESPACE + f"/api/v1/users/{user_id}", headers=auth_header) |
| 99 | + assert response.status_code == 200 |
| 100 | + |
| 101 | + @pytest.mark.parametrize("as_admin", [False, True]) |
| 102 | + @pytest.mark.parametrize("props", [{"custom_ca_cert": "/server/private-file"}, {}, None, "invalid", []]) |
| 103 | + def test_create_rejects_props(self, client, auth_header, creator_header, as_admin, props): |
| 104 | + url = FLARE_DASHBOARD_NAMESPACE + "/api/v1/clients" |
| 105 | + before = client.get(url, headers=auth_header).json["client_list"] |
| 106 | + response = client.post( |
| 107 | + url, |
| 108 | + json={**CLIENT1, "name": f"props-{uuid4().hex}", "props": props}, |
| 109 | + headers=auth_header if as_admin else creator_header, |
| 110 | + ) |
| 111 | + try: |
| 112 | + assert response.status_code == 400 |
| 113 | + assert response.json["status"] == "error" |
| 114 | + assert "props" in response.json["message"] |
| 115 | + assert client.get(url, headers=auth_header).json["client_list"] == before |
| 116 | + finally: |
| 117 | + if response.status_code == 201: |
| 118 | + client.delete(url + f"/{response.json['client']['id']}", headers=auth_header) |
| 119 | + |
| 120 | + @pytest.mark.parametrize("as_admin", [False, True]) |
| 121 | + @pytest.mark.parametrize("approval_state", [0, 100, 200]) |
| 122 | + @pytest.mark.parametrize("props", [{"custom_ca_cert": "/server/private-file"}, {}, None, "invalid", []]) |
| 123 | + def test_patch_rejects_props(self, client, auth_header, creator_header, as_admin, approval_state, props): |
| 124 | + response = client.post( |
| 125 | + FLARE_DASHBOARD_NAMESPACE + "/api/v1/clients", |
| 126 | + json={**CLIENT1, "name": f"props-{uuid4().hex}"}, |
| 127 | + headers=creator_header, |
| 128 | + ) |
| 129 | + assert response.status_code == 201 |
| 130 | + url = FLARE_DASHBOARD_NAMESPACE + f"/api/v1/clients/{response.json['client']['id']}" |
| 131 | + try: |
| 132 | + response = client.patch(url, json={"approval_state": approval_state}, headers=auth_header) |
| 133 | + assert response.status_code == 200 |
| 134 | + before = client.get(url, headers=auth_header).json["client"] |
| 135 | + response = client.patch( |
| 136 | + url, |
| 137 | + json={"props": props, "name": "changed-name", "approval_state": -1}, |
| 138 | + headers=auth_header if as_admin else creator_header, |
| 139 | + ) |
| 140 | + assert response.status_code == 400 |
| 141 | + assert response.json["status"] == "error" |
| 142 | + assert "props" in response.json["message"] |
| 143 | + assert client.get(url, headers=auth_header).json["client"] == before |
| 144 | + |
| 145 | + # Ordinary client fields remain editable when props is absent. |
| 146 | + capacity = {"num_of_gpus": 2, "mem_per_gpu_in_GiB": 16} |
| 147 | + response = client.patch(url, json={"capacity": capacity}, headers=creator_header) |
| 148 | + assert response.status_code == 200 |
| 149 | + assert response.json["client"]["capacity"] == capacity |
| 150 | + finally: |
| 151 | + response = client.delete(url, headers=auth_header) |
| 152 | + assert response.status_code == 200 |
| 153 | + |
| 154 | + @pytest.mark.parametrize("malformed_props", [False, True]) |
| 155 | + def test_blob_ignores_legacy_props( |
| 156 | + self, app, client, auth_header, creator_header, tmp_path, monkeypatch, malformed_props |
| 157 | + ): |
| 158 | + secret = b"legacy-client-props-must-not-disclose-this-file" |
| 159 | + target = tmp_path / "private-file" |
| 160 | + target.write_bytes(secret) |
| 161 | + legacy_props = ( |
| 162 | + "invalid JSON" |
| 163 | + if malformed_props |
| 164 | + else json.dumps({"custom_ca_cert": str(target), "connection_security": "clear"}) |
| 165 | + ) |
| 166 | + # Operator-managed properties and the client's capacity must still reach the kit. |
| 167 | + monkeypatch.setenv("NVFL_WEB_ROOT", str(tmp_path)) |
| 168 | + (tmp_path / "properties.json").write_text(json.dumps({"client": {"use_aio": True}})) |
| 169 | + capacity = {"num_of_gpus": 2, "mem_per_gpu_in_GiB": 16} |
| 170 | + response = client.post( |
| 171 | + FLARE_DASHBOARD_NAMESPACE + "/api/v1/clients", |
| 172 | + json={"name": f"legacy-{uuid4().hex}", "organization": "testorg", "capacity": capacity}, |
| 173 | + headers=creator_header, |
| 174 | + ) |
| 175 | + assert response.status_code == 201 |
| 176 | + client_id = response.json["client"]["id"] |
| 177 | + url = FLARE_DASHBOARD_NAMESPACE + f"/api/v1/clients/{client_id}" |
| 178 | + with app.app_context(): |
| 179 | + project = Project.query.first() |
| 180 | + original = {key: getattr(project, key) for key in ("short_name", "server1", "root_key", "root_cert")} |
| 181 | + project.short_name = "legacy-test" |
| 182 | + project.server1 = "server.test.com" |
| 183 | + Store.build_project(project) |
| 184 | + legacy_client = db.session.get(Client, client_id) |
| 185 | + legacy_client.props = legacy_props |
| 186 | + legacy_client.approval_state = 200 |
| 187 | + db.session.commit() |
| 188 | + |
| 189 | + try: |
| 190 | + response = client.post(url + "/blob", json={"pin": "1234"}, headers=creator_header) |
| 191 | + assert response.status_code == 200 |
| 192 | + assert response.headers["Content-Type"] == "zip" |
| 193 | + with ZipFile(io.BytesIO(response.data)) as kit: |
| 194 | + assert not any(name.endswith("customRootCA.pem") for name in kit.namelist()) |
| 195 | + for name in kit.namelist(): |
| 196 | + if not name.endswith("/"): |
| 197 | + assert secret not in kit.read(name, pwd=b"1234") |
| 198 | + resources = json.loads(kit.read("local/resources.json.default", pwd=b"1234")) |
| 199 | + resource_manager = next(c for c in resources["components"] if c["id"] == "resource_manager") |
| 200 | + for key, value in capacity.items(): |
| 201 | + assert resource_manager["args"][key] == value |
| 202 | + config = json.loads(kit.read("startup/fed_client.json", pwd=b"1234")) |
| 203 | + assert config["client"]["connection_security"] == "mtls" |
| 204 | + assert config["servers"][0]["service"]["scheme"] == "agrpc" |
| 205 | + |
| 206 | + with app.app_context(): |
| 207 | + # Safety comes from ignoring stored props, without depending on a migration. |
| 208 | + assert db.session.get(Client, client_id).props == legacy_props |
| 209 | + finally: |
| 210 | + response = client.delete(url, headers=auth_header) |
| 211 | + assert response.status_code == 200 |
| 212 | + with app.app_context(): |
| 213 | + project = Project.query.first() |
| 214 | + for key, value in original.items(): |
| 215 | + setattr(project, key, value) |
| 216 | + db.session.commit() |
0 commit comments