From d6b5afc00fb3394fba7ef4ab5d8840d9f34a590c Mon Sep 17 00:00:00 2001 From: Luigi Pellecchia Date: Wed, 22 Apr 2026 17:08:17 +0200 Subject: [PATCH 1/5] Add folder management, move/rename, and nested path support to User Files Extend the User Files feature with full directory support: users can now create folders, navigate into them via breadcrumb navigation, rename and move files or directories, and delete directories recursively. All new and existing endpoints are hardened against path traversal attacks. Backend (api/api.py, api/api_utils.py): - Add `is_safe_user_path()` utility that resolves symlinks and verifies the target stays under the user's root directory; applied to every UserFiles, UserFileContent, and UserFileFolder endpoint - GET /user/files now accepts `path` (sub-directory listing) and `recursive` (walk all nested files) query parameters; response includes `name`, `type` ("file" or "directory"), and `relative_path` fields; sorting places directories before files - POST /user/files supports nested filenames (e.g. "folder/sub/file.txt") by auto-creating intermediate directories; dot-filename validation now checks only the basename so nested paths are accepted - PUT /user/files (new) moves or renames a file or directory, with conflict detection and auto-creation of the destination parent directory - DELETE /user/files now handles directories via `shutil.rmtree` in addition to single files; response uses `api_response.return_ok()` consistently - POST /user/files/folder (new `UserFileFolder` resource) creates a directory, supporting nested paths and rejecting dot-prefixed leaf names Frontend (app/src/app/UserFiles/*, Constants/constants.tsx): - Add `createUserFolder`, `moveUserFile`, and `deleteUserFile` helper functions in constants.tsx; `loadUserFiles` gains `_path` and `_recursive` parameters; `loadFileContent` URL-encodes the filename for nested paths - Forms that list user files (APIForm, DocumentForm, TestCaseForm, TestRunConfigForm, TestCaseImport) now request a recursive listing and display `relative_path` as the label so nested files are distinguishable - New form components: `UserFilesCreateFolderForm`, `UserFilesRenameForm`, `UserFilesMoveForm`, `UserFilesDeleteConfirm` - `UserFilesModal` becomes a multi-action modal dispatching to the correct form based on the action (add / edit / create-folder / rename / move / delete); delete action uses a danger-styled confirm button - `UserFilesMenuKebab` replaces inline delete logic with a unified `openModal` helper exposing Edit, Rename, Move, and Delete actions; Edit is hidden for directories - `UserFiles` page adds breadcrumb navigation for folder drill-down and a "New Folder" button; listing refreshes without full page reloads - `UserFilesListing` shows folder/file icons, clickable folder names for navigation, and an empty-state message Tests (api/test/): - `user_files_test_helpers.py`: add `move_file`, `create_folder` helpers; `remove_if_exists` now handles directories - `conftest.py`: `ut_user_files_dir` cleanup removes directories as well - `test_user_files.py`: new tests for type field, directory-first sorting, `path` param, path traversal blocking, recursive listing, nested file creation, directory deletion, move/rename (file, directory, into subfolder, conflict, empty fields, path traversal) - `test_user_file_content.py`: new tests for nested file GET/PUT and path traversal blocking on both endpoints - `test_user_file_folder.py` (new): tests for folder creation (auth, missing fields, empty name, dot name, success, nested, conflict, path traversal) Signed-off-by: Luigi Pellecchia --- api/api.py | 261 ++++++++++++-- api/api_utils.py | 7 + api/test/conftest.py | 3 + api/test/test_user_file_content.py | 58 +++ api/test/test_user_file_folder.py | 105 ++++++ api/test/test_user_files.py | 331 +++++++++++++++++- api/test/user_files_test_helpers.py | 15 +- app/src/app/Constants/constants.tsx | 92 ++++- app/src/app/Dashboard/Form/APIForm.tsx | 6 +- app/src/app/Mapping/Form/DocumentForm.tsx | 4 +- app/src/app/Mapping/Form/TestCaseForm.tsx | 4 +- .../app/Mapping/Form/TestRunConfigForm.tsx | 6 +- app/src/app/Mapping/Import/TestCaseImport.tsx | 10 +- .../app/UserFiles/Form/UserFilesAddForm.tsx | 22 +- .../Form/UserFilesCreateFolderForm.tsx | 80 +++++ .../UserFiles/Form/UserFilesDeleteConfirm.tsx | 63 ++++ .../app/UserFiles/Form/UserFilesMoveForm.tsx | 87 +++++ .../UserFiles/Form/UserFilesRenameForm.tsx | 94 +++++ .../app/UserFiles/Menu/UserFilesMenuKebab.tsx | 69 ++-- .../app/UserFiles/Modal/UserFilesModal.tsx | 127 ++++++- app/src/app/UserFiles/UserFiles.tsx | 79 ++++- app/src/app/UserFiles/UserFilesListing.tsx | 46 ++- pyproject.toml | 2 +- 23 files changed, 1452 insertions(+), 119 deletions(-) create mode 100644 api/test/test_user_file_folder.py create mode 100644 app/src/app/UserFiles/Form/UserFilesCreateFolderForm.tsx create mode 100644 app/src/app/UserFiles/Form/UserFilesDeleteConfirm.tsx create mode 100644 app/src/app/UserFiles/Form/UserFilesMoveForm.tsx create mode 100644 app/src/app/UserFiles/Form/UserFilesRenameForm.tsx diff --git a/api/api.py b/api/api.py index d0db7a13..43b680d6 100644 --- a/api/api.py +++ b/api/api.py @@ -88,6 +88,7 @@ get_user_pdf_folder_path, get_user_traceability_scanner_config, is_safe_local_user_file_path, + is_safe_user_path, is_testing_enabled_by_env, load_settings, parse_int, @@ -146,6 +147,7 @@ if not os.path.exists(USER_FILES_BASE_DIR): os.makedirs(USER_FILES_BASE_DIR, exist_ok=True) + # Read API Version once # API Version is not supposed to change runtime API_VERSION = "" @@ -956,6 +958,8 @@ def get_query_string_args(args): "test-case-id", "test_run_config_id", "test_runs_limit", + "path", + "recursive", "token", "url", "user-id", @@ -9707,27 +9711,68 @@ def get(self, api_response: ApiResponse = None): api_response.set_data(ret) return api_response.return_ok() - i = 0 - for user_file in os.listdir(user_files_path): + sub_path = request_data.get("path", "") + recursive = str(request_data.get("recursive", "")).lower() == "true" - # Skip hidden files - if user_file.startswith("."): - continue + if sub_path: + listing_path = os.path.join(user_files_path, sub_path) + else: + listing_path = user_files_path - tmp = { - "index": i, - "filepath": os.path.join(user_files_path, user_file), - "updated_at": time.ctime(os.path.getmtime(os.path.join(user_files_path, user_file))), - } + if not is_safe_user_path(user_files_path, listing_path): + api_response.set_message("Invalid path") + return api_response.return_bad_request() + + if not os.path.isdir(listing_path): + api_response.set_message("Directory not found") + return api_response.return_not_found() - if "filter" in request_data.keys(): - if str(request_data["filter"]).lower() not in user_file.lower(): + if recursive: + i = 0 + for root, _dirs, files in os.walk(listing_path): + _dirs[:] = [d for d in _dirs if not d.startswith(".")] + for fname in sorted(files): + if fname.startswith("."): + continue + full = os.path.join(root, fname) + rel = os.path.relpath(full, user_files_path) + if "filter" in request_data: + if str(request_data["filter"]).lower() not in fname.lower(): + continue + ret.append({ + "index": i, + "filepath": full, + "relative_path": rel, + "name": fname, + "type": "file", + "updated_at": time.ctime(os.path.getmtime(full)), + }) + i += 1 + else: + i = 0 + for entry_name in os.listdir(listing_path): + if entry_name.startswith("."): continue - ret.append(tmp) - i += 1 + full = os.path.join(listing_path, entry_name) + rel = os.path.relpath(full, user_files_path) + entry_type = "directory" if os.path.isdir(full) else "file" + + if "filter" in request_data: + if str(request_data["filter"]).lower() not in entry_name.lower(): + continue + + ret.append({ + "index": i, + "filepath": full, + "relative_path": rel, + "name": entry_name, + "type": entry_type, + "updated_at": time.ctime(os.path.getmtime(full)), + }) + i += 1 - ret = sorted(ret, key=lambda f: f["filepath"], reverse=False) # sort by filename + ret = sorted(ret, key=lambda f: (f["type"] != "directory", f["name"].lower())) api_response.set_data(ret) return api_response.return_ok() @@ -9746,7 +9791,10 @@ def post(self, api_response: ApiResponse = None): api_response.set_missing_fields(wrong_fields) return api_response.return_bad_request_missing_fields() - if request_data["filename"].startswith("."): + filename = request_data["filename"] + basename = os.path.basename(filename) + + if basename.startswith("."): api_response.set_message("Filename cannot start with a dot") return api_response.return_bad_request() @@ -9763,10 +9811,13 @@ def post(self, api_response: ApiResponse = None): if not os.path.exists(user_files_path): os.makedirs(user_files_path, exist_ok=True) - filename = request_data["filename"] filecontent = request_data["filecontent"] filepath = os.path.join(user_files_path, filename) + if not is_safe_user_path(user_files_path, filepath): + api_response.set_message("Invalid path") + return api_response.return_bad_request() + if filename == "": api_response.set_message("Missing filename value") return api_response.return_bad_request() @@ -9778,6 +9829,9 @@ def post(self, api_response: ApiResponse = None): api_response.set_message("File already exists") return api_response.return_conflict() + parent_dir = os.path.dirname(filepath) + os.makedirs(parent_dir, exist_ok=True) + f = open(filepath, "w") f.write(filecontent) f.close() @@ -9789,15 +9843,88 @@ def post(self, api_response: ApiResponse = None): ret = { "index": 0, "filepath": filepath, + "relative_path": os.path.relpath(filepath, user_files_path), + "name": os.path.basename(filepath), + "type": "file", "updated_at": time.ctime(os.path.getmtime(filepath)) } api_response.set_data(ret) return api_response.return_created() + @api_response_decorator + def put(self, api_response: ApiResponse = None): + """ + move or rename a user file or directory + """ + request_data = request.get_json(force=True) + api_response.set_logger(logger) + api_response.set_args(request_data) + + mandatory_fields = ["source", "destination"] + wrong_fields = get_wrong_mandatory_fields(mandatory_fields, request_data) + if len(wrong_fields) > 0: + api_response.set_missing_fields(wrong_fields) + return api_response.return_bad_request_missing_fields() + + dbi = get_db() + + user_id = get_user_id_from_request(request_data, dbi.session) + if user_id == 0: + return api_response.return_unauthorized() + + dbi.close() + + user_files_path = os.path.join(USER_FILES_BASE_DIR, f"{user_id}") + if not os.path.exists(user_files_path): + os.makedirs(user_files_path, exist_ok=True) + + source = request_data["source"] + destination = request_data["destination"] + + if source == "" or destination == "": + api_response.set_message("Source and destination must not be empty") + return api_response.return_bad_request() + + src_path = os.path.join(user_files_path, source) + dst_path = os.path.join(user_files_path, destination) + + if not is_safe_user_path(user_files_path, src_path): + api_response.set_message("Invalid source path") + return api_response.return_bad_request() + + if not is_safe_user_path(user_files_path, dst_path): + api_response.set_message("Invalid destination path") + return api_response.return_bad_request() + + if not os.path.exists(src_path): + api_response.set_message("Source not found") + return api_response.return_not_found() + + if os.path.exists(dst_path): + api_response.set_message("Destination already exists") + return api_response.return_conflict() + + dst_parent = os.path.dirname(dst_path) + os.makedirs(dst_parent, exist_ok=True) + + shutil.move(src_path, dst_path) + + entry_type = "directory" if os.path.isdir(dst_path) else "file" + ret = { + "index": 0, + "filepath": dst_path, + "relative_path": os.path.relpath(dst_path, user_files_path), + "name": os.path.basename(dst_path), + "type": entry_type, + "updated_at": time.ctime(os.path.getmtime(dst_path)), + } + api_response.set_data(ret) + return api_response.return_ok() + @api_response_decorator def delete(self, api_response: ApiResponse = None): """ - delete user file + delete user file or directory """ request_data = request.get_json(force=True) api_response.set_logger(logger) @@ -9821,6 +9948,7 @@ def delete(self, api_response: ApiResponse = None): user_files_path = os.path.join(USER_FILES_BASE_DIR, f"{user_id}") if not os.path.exists(user_files_path): os.makedirs(user_files_path, exist_ok=True) + api_response.set_message("File not found") return api_response.return_not_found() filename = request_data["filename"] @@ -9830,20 +9958,95 @@ def delete(self, api_response: ApiResponse = None): api_response.set_message("Missing filename value") return api_response.return_bad_request() + if not is_safe_user_path(user_files_path, filepath): + api_response.set_message("Invalid path") + return api_response.return_bad_request() + if not os.path.exists(filepath): api_response.set_message("File not found") return api_response.return_not_found() - os.remove(filepath) + if os.path.isdir(filepath): + shutil.rmtree(filepath) + else: + os.remove(filepath) if os.path.exists(filepath): - api_response.set_message("File not found") + api_response.set_message("Failed to delete") return api_response.return_not_found() - ret = {"index": 0, - "filepath": filepath, - "updated_at": ""} - return ret + ret = { + "index": 0, + "filepath": filepath, + "relative_path": os.path.relpath(filepath, user_files_path), + "name": os.path.basename(filepath), + "updated_at": "", + } + api_response.set_data(ret) + return api_response.return_ok() + + +class UserFileFolder(Resource): + route = "/user/files/folder" + + @api_response_decorator + def post(self, api_response: ApiResponse = None): + """ + create a user folder + """ + request_data = request.get_json(force=True) + api_response.set_logger(logger) + api_response.set_args(request_data) + + mandatory_fields = ["foldername"] + wrong_fields = get_wrong_mandatory_fields(mandatory_fields, request_data) + if len(wrong_fields) > 0: + api_response.set_missing_fields(wrong_fields) + return api_response.return_bad_request_missing_fields() + + dbi = get_db() + + user_id = get_user_id_from_request(request_data, dbi.session) + if user_id == 0: + return api_response.return_unauthorized() + + dbi.close() + + user_files_path = os.path.join(USER_FILES_BASE_DIR, f"{user_id}") + if not os.path.exists(user_files_path): + os.makedirs(user_files_path, exist_ok=True) + + foldername = request_data["foldername"] + if foldername == "": + api_response.set_message("Missing foldername value") + return api_response.return_bad_request() + + if os.path.basename(foldername).startswith("."): + api_response.set_message("Folder name cannot start with a dot") + return api_response.return_bad_request() + + folderpath = os.path.join(user_files_path, foldername) + + if not is_safe_user_path(user_files_path, folderpath): + api_response.set_message("Invalid path") + return api_response.return_bad_request() + + if os.path.exists(folderpath): + api_response.set_message("Folder already exists") + return api_response.return_conflict() + + os.makedirs(folderpath, exist_ok=True) + + ret = { + "index": 0, + "filepath": folderpath, + "relative_path": os.path.relpath(folderpath, user_files_path), + "name": os.path.basename(folderpath), + "type": "directory", + "updated_at": time.ctime(os.path.getmtime(folderpath)), + } + api_response.set_data(ret) + return api_response.return_created() class UserFileContent(Resource): @@ -9881,6 +10084,11 @@ def get(self, api_response: ApiResponse = None): return api_response.return_not_found() filepath = os.path.join(user_files_path, request_data["filename"]) + + if not is_safe_user_path(user_files_path, filepath): + api_response.set_message("Invalid path") + return api_response.return_bad_request() + if not os.path.exists(filepath): return api_response.return_not_found() @@ -9930,6 +10138,10 @@ def put(self, api_response: ApiResponse = None): filecontent = request_data["filecontent"] filepath = os.path.join(user_files_path, filename) + if not is_safe_user_path(user_files_path, filepath): + api_response.set_message("Invalid path") + return api_response.return_bad_request() + if filename == "" or filecontent == "": return api_response.return_bad_request() @@ -11711,6 +11923,7 @@ def get(self, api_response: ApiResponse = None): api.add_resource(UserSignin, UserSignin.route) api.add_resource(UserSshKey, UserSshKey.route) api.add_resource(UserFiles, UserFiles.route) +api.add_resource(UserFileFolder, UserFileFolder.route) api.add_resource(UserFileContent, UserFileContent.route) api.add_resource(Alert, Alert.route) api.add_resource(Testing, Testing.route) diff --git a/api/api_utils.py b/api/api_utils.py index 9bdfbc5e..db70ca1f 100644 --- a/api/api_utils.py +++ b/api/api_utils.py @@ -779,6 +779,13 @@ def get_custom_actions(user: UserModel) -> list: return actions +def is_safe_user_path(user_root, requested_path): + """Return True only if *requested_path* resolves under *user_root*.""" + abs_root = os.path.realpath(user_root) + abs_target = os.path.realpath(requested_path) + return abs_target == abs_root or abs_target.startswith(abs_root + os.sep) + + def is_safe_local_user_file_path(path: str) -> bool: from api import USER_FILES_BASE_DIR return path.startswith(os.path.abspath(USER_FILES_BASE_DIR) + os.sep) diff --git a/api/test/conftest.py b/api/test/conftest.py index a5fe41ab..c9b527d4 100644 --- a/api/test/conftest.py +++ b/api/test/conftest.py @@ -113,6 +113,7 @@ def reader_authentication(client, ut_reader_user_db): @pytest.fixture(scope="module") def ut_user_files_dir(ut_user_db): """Ensure the UT user has a clean user-files directory for this test.""" + import shutil from user_files_test_helpers import user_files_dir base = user_files_dir(ut_user_db.id) @@ -124,3 +125,5 @@ def ut_user_files_dir(ut_user_db): p = os.path.join(base, name) if os.path.isfile(p): os.remove(p) + elif os.path.isdir(p): + shutil.rmtree(p) diff --git a/api/test/test_user_file_content.py b/api/test/test_user_file_content.py index f48c6b9b..98eeec4d 100644 --- a/api/test/test_user_file_content.py +++ b/api/test/test_user_file_content.py @@ -94,6 +94,33 @@ def test_user_file_content_get_ok(client, user_authentication, utilities): remove_if_exists(path) +def test_user_file_content_get_nested_file(client, user_authentication, utilities): + """Content of a file in a nested folder should be accessible via relative path.""" + auth = user_authentication.json + suffix = utilities.generate_random_hex_string8() + nested = f"{UT_PREFIX}cnt_{suffix}/sub/deep.txt" + base = user_files_dir(auth["id"]) + top_dir = os.path.join(base, f"{UT_PREFIX}cnt_{suffix}") + + try: + post_file(client, auth, nested, "deep content") + response = get_content(client, auth, nested) + assert response.status_code == HTTPStatus.OK + assert response.get_json()["filecontent"] == "deep content" + finally: + remove_if_exists(top_dir) + + +def test_user_file_content_get_path_traversal_blocked(client, user_authentication): + auth = user_authentication.json + response = get_content(client, auth, "../../etc/passwd") + assert response.status_code == HTTPStatus.BAD_REQUEST + + +# --------------------------------------------------------------------------- +# PUT /user/files/content +# --------------------------------------------------------------------------- + @pytest.mark.parametrize("mandatory_field", ["filename", "filecontent"]) def test_user_file_content_put_missing_mandatory_fields(client, user_authentication, mandatory_field): auth = user_authentication.json @@ -168,3 +195,34 @@ def test_user_file_content_put_updates_content(client, user_authentication, util assert f.read() == "updated" finally: remove_if_exists(path) + + +def test_user_file_content_put_nested_file(client, user_authentication, utilities): + """PUT should work for files in nested directories.""" + auth = user_authentication.json + suffix = utilities.generate_random_hex_string8() + nested = f"{UT_PREFIX}putcnt_{suffix}/sub/deep.txt" + base = user_files_dir(auth["id"]) + top_dir = os.path.join(base, f"{UT_PREFIX}putcnt_{suffix}") + + try: + post_file(client, auth, nested, "original") + response = put_content(client, auth, nested, "updated-deep") + assert response.status_code == HTTPStatus.OK + assert response.get_json()["filecontent"] == "updated-deep" + finally: + remove_if_exists(top_dir) + + +def test_user_file_content_put_path_traversal_blocked(client, user_authentication, utilities): + auth = user_authentication.json + suffix = utilities.generate_random_hex_string8() + name = f"{UT_PREFIX}tr_{suffix}.txt" + base = user_files_dir(auth["id"]) + + try: + post_file(client, auth, name, "x") + response = put_content(client, auth, f"../../{name}", "hacked") + assert response.status_code == HTTPStatus.BAD_REQUEST + finally: + remove_if_exists(os.path.join(base, name)) diff --git a/api/test/test_user_file_folder.py b/api/test/test_user_file_folder.py new file mode 100644 index 00000000..e102057b --- /dev/null +++ b/api/test/test_user_file_folder.py @@ -0,0 +1,105 @@ +"""HTTP tests for UserFileFolder (/user/files/folder).""" +import os +from http import HTTPStatus + +from user_files_test_helpers import ( + USER_FILE_FOLDER_URL, + UT_PREFIX, + auth_json_body, + create_folder, + remove_if_exists, + user_files_dir, +) + + +def test_user_file_folder_post_unauthorized(client, user_authentication): + auth = user_authentication.json + response = client.post( + USER_FILE_FOLDER_URL, + json={"user-id": auth["id"], "token": "bad", "foldername": "test"}, + ) + assert response.status_code == HTTPStatus.UNAUTHORIZED + + +def test_user_file_folder_post_missing_foldername(client, user_authentication): + auth = user_authentication.json + body = auth_json_body(auth) + response = client.post(USER_FILE_FOLDER_URL, json=body) + assert response.status_code == HTTPStatus.BAD_REQUEST + + +def test_user_file_folder_post_empty_foldername(client, user_authentication): + auth = user_authentication.json + response = create_folder(client, auth, "") + assert response.status_code == HTTPStatus.BAD_REQUEST + + +def test_user_file_folder_post_dot_foldername(client, user_authentication): + auth = user_authentication.json + response = create_folder(client, auth, ".hidden") + assert response.status_code == HTTPStatus.BAD_REQUEST + + +def test_user_file_folder_post_created(client, user_authentication, utilities): + auth = user_authentication.json + suffix = utilities.generate_random_hex_string8() + name = f"{UT_PREFIX}folder_{suffix}" + base = user_files_dir(auth["id"]) + folder_path = os.path.join(base, name) + + try: + response = create_folder(client, auth, name) + assert response.status_code == HTTPStatus.CREATED + data = response.get_json() + assert data["name"] == name + assert data["type"] == "directory" + assert data["relative_path"] == name + assert os.path.isdir(folder_path) + finally: + remove_if_exists(folder_path) + + +def test_user_file_folder_post_nested(client, user_authentication, utilities): + """Creating nested folders (e.g. 'a/b/c') should work.""" + auth = user_authentication.json + suffix = utilities.generate_random_hex_string8() + name = f"{UT_PREFIX}nest_{suffix}/sub/deep" + base = user_files_dir(auth["id"]) + top_dir = os.path.join(base, f"{UT_PREFIX}nest_{suffix}") + + try: + response = create_folder(client, auth, name) + assert response.status_code == HTTPStatus.CREATED + assert response.get_json()["type"] == "directory" + assert os.path.isdir(os.path.join(base, name)) + finally: + remove_if_exists(top_dir) + + +def test_user_file_folder_post_conflict(client, user_authentication, utilities): + auth = user_authentication.json + suffix = utilities.generate_random_hex_string8() + name = f"{UT_PREFIX}dup_{suffix}" + base = user_files_dir(auth["id"]) + + try: + first = create_folder(client, auth, name) + assert first.status_code == HTTPStatus.CREATED + second = create_folder(client, auth, name) + assert second.status_code == HTTPStatus.CONFLICT + finally: + remove_if_exists(os.path.join(base, name)) + + +def test_user_file_folder_post_path_traversal_blocked(client, user_authentication): + auth = user_authentication.json + response = create_folder(client, auth, "../../escape") + assert response.status_code == HTTPStatus.BAD_REQUEST + + +def test_user_file_folder_post_nested_dot_blocked(client, user_authentication, utilities): + """Folder paths where the leaf starts with a dot should be rejected.""" + auth = user_authentication.json + suffix = utilities.generate_random_hex_string8() + response = create_folder(client, auth, f"parent_{suffix}/.secret") + assert response.status_code == HTTPStatus.BAD_REQUEST diff --git a/api/test/test_user_files.py b/api/test/test_user_files.py index efd01cce..24ca00cd 100644 --- a/api/test/test_user_files.py +++ b/api/test/test_user_files.py @@ -11,12 +11,17 @@ auth_query, delete_file, get_files, + move_file, post_file, remove_if_exists, user_files_dir, ) +# --------------------------------------------------------------------------- +# GET /user/files – auth +# --------------------------------------------------------------------------- + def test_user_files_get_unauthorized_without_credentials(client): response = client.get(USER_FILES_URL) assert response.status_code == HTTPStatus.UNAUTHORIZED @@ -41,8 +46,11 @@ def test_user_files_get_unauthorized_missing_auth_query_keys( assert response.status_code == HTTPStatus.UNAUTHORIZED +# --------------------------------------------------------------------------- +# GET /user/files – listing +# --------------------------------------------------------------------------- + def test_user_files_get_ok_empty_directory(client, user_authentication, ut_user_files_dir): - """New or empty user folder returns an empty list.""" auth = user_authentication.json for name in os.listdir(ut_user_files_dir): if not name.startswith("."): @@ -75,10 +83,9 @@ def test_user_files_get_lists_non_hidden_files_sorted( response = get_files(client, auth) assert response.status_code == HTTPStatus.OK rows = response.get_json() - names = [os.path.basename(r["filepath"]) for r in rows] + names = [r["name"] for r in rows] assert a_name in names and b_name in names assert f".hidden_{suffix}" not in names - assert names == sorted(names) finally: remove_if_exists(a_path) remove_if_exists(b_path) @@ -101,7 +108,7 @@ def test_user_files_get_filter_query(client, user_authentication, ut_user_files_ response = get_files(client, auth, extra_query={"filter": "alpha"}) assert response.status_code == HTTPStatus.OK - names = [os.path.basename(r["filepath"]) for r in response.get_json()] + names = [r["name"] for r in response.get_json()] assert match_name in names assert other_name not in names finally: @@ -109,6 +116,129 @@ def test_user_files_get_filter_query(client, user_authentication, ut_user_files_ remove_if_exists(o_path) +def test_user_files_get_returns_type_field(client, user_authentication, ut_user_files_dir, utilities): + auth = user_authentication.json + suffix = utilities.generate_random_hex_string8() + file_name = f"{UT_PREFIX}typed_{suffix}.txt" + dir_name = f"{UT_PREFIX}dir_{suffix}" + f_path = os.path.join(ut_user_files_dir, file_name) + d_path = os.path.join(ut_user_files_dir, dir_name) + + try: + with open(f_path, "w", encoding="utf-8") as f: + f.write("c") + os.makedirs(d_path, exist_ok=True) + + response = get_files(client, auth) + assert response.status_code == HTTPStatus.OK + rows = response.get_json() + types = {r["name"]: r["type"] for r in rows} + assert types.get(file_name) == "file" + assert types.get(dir_name) == "directory" + finally: + remove_if_exists(f_path) + remove_if_exists(d_path) + + +def test_user_files_get_directories_sorted_first(client, user_authentication, ut_user_files_dir, utilities): + auth = user_authentication.json + suffix = utilities.generate_random_hex_string8() + file_name = f"{UT_PREFIX}aaa_{suffix}.txt" + dir_name = f"{UT_PREFIX}zzz_dir_{suffix}" + f_path = os.path.join(ut_user_files_dir, file_name) + d_path = os.path.join(ut_user_files_dir, dir_name) + + try: + with open(f_path, "w", encoding="utf-8") as f: + f.write("c") + os.makedirs(d_path, exist_ok=True) + + response = get_files(client, auth) + assert response.status_code == HTTPStatus.OK + rows = response.get_json() + relevant = [r for r in rows if r["name"] in (file_name, dir_name)] + assert len(relevant) == 2 + assert relevant[0]["type"] == "directory" + assert relevant[1]["type"] == "file" + finally: + remove_if_exists(f_path) + remove_if_exists(d_path) + + +# --------------------------------------------------------------------------- +# GET /user/files – nested path param +# --------------------------------------------------------------------------- + +def test_user_files_get_with_path_param(client, user_authentication, ut_user_files_dir, utilities): + auth = user_authentication.json + suffix = utilities.generate_random_hex_string8() + sub_dir = f"{UT_PREFIX}sub_{suffix}" + sub_path = os.path.join(ut_user_files_dir, sub_dir) + file_name = f"nested_{suffix}.txt" + file_path = os.path.join(sub_path, file_name) + + try: + os.makedirs(sub_path, exist_ok=True) + with open(file_path, "w", encoding="utf-8") as f: + f.write("nested content") + + response = get_files(client, auth, extra_query={"path": sub_dir}) + assert response.status_code == HTTPStatus.OK + rows = response.get_json() + names = [r["name"] for r in rows] + assert file_name in names + finally: + remove_if_exists(sub_path) + + +def test_user_files_get_path_not_found(client, user_authentication, utilities): + auth = user_authentication.json + response = get_files(client, auth, extra_query={"path": f"nonexistent_{utilities.generate_random_hex_string8()}"}) + assert response.status_code == HTTPStatus.NOT_FOUND + + +def test_user_files_get_path_traversal_blocked(client, user_authentication): + auth = user_authentication.json + response = get_files(client, auth, extra_query={"path": "../"}) + assert response.status_code == HTTPStatus.BAD_REQUEST + + +# --------------------------------------------------------------------------- +# GET /user/files – recursive +# --------------------------------------------------------------------------- + +def test_user_files_get_recursive(client, user_authentication, ut_user_files_dir, utilities): + auth = user_authentication.json + suffix = utilities.generate_random_hex_string8() + sub_dir = f"{UT_PREFIX}rec_{suffix}" + sub_path = os.path.join(ut_user_files_dir, sub_dir) + root_file = f"{UT_PREFIX}root_{suffix}.txt" + nested_file = f"nested_{suffix}.txt" + + try: + os.makedirs(sub_path, exist_ok=True) + with open(os.path.join(ut_user_files_dir, root_file), "w") as f: + f.write("root") + with open(os.path.join(sub_path, nested_file), "w") as f: + f.write("nested") + + response = get_files(client, auth, extra_query={"recursive": "true"}) + assert response.status_code == HTTPStatus.OK + rows = response.get_json() + names = [r["name"] for r in rows] + assert root_file in names + assert nested_file in names + for r in rows: + assert r["type"] == "file" + finally: + remove_if_exists(os.path.join(ut_user_files_dir, root_file)) + remove_if_exists(sub_path) + + +# --------------------------------------------------------------------------- +# POST /user/files +# --------------------------------------------------------------------------- + @pytest.mark.parametrize("mandatory_field", ["filename", "filecontent"]) def test_user_files_post_missing_mandatory_fields(client, user_authentication, mandatory_field): auth = user_authentication.json @@ -175,6 +305,8 @@ def test_user_files_post_created(client, user_authentication, utilities): assert response.status_code == HTTPStatus.CREATED data = response.get_json() assert data["filepath"] == fpath + assert data["name"] == name + assert data["type"] == "file" assert "updated_at" in data assert os.path.isfile(fpath) with open(fpath, encoding="utf-8") as f: @@ -183,6 +315,43 @@ def test_user_files_post_created(client, user_authentication, utilities): remove_if_exists(fpath) +def test_user_files_post_creates_nested_directories(client, user_authentication, utilities): + """Upload to a nested path should auto-create intermediate folders.""" + auth = user_authentication.json + suffix = utilities.generate_random_hex_string8() + nested = f"{UT_PREFIX}nested_{suffix}/sub/file.txt" + base = user_files_dir(auth["id"]) + top_dir = os.path.join(base, f"{UT_PREFIX}nested_{suffix}") + + try: + response = post_file(client, auth, nested, "nested content") + assert response.status_code == HTTPStatus.CREATED + data = response.get_json() + assert data["name"] == "file.txt" + assert data["relative_path"] == nested + assert os.path.isfile(os.path.join(base, nested)) + finally: + remove_if_exists(top_dir) + + +def test_user_files_post_path_traversal_blocked(client, user_authentication): + auth = user_authentication.json + response = post_file(client, auth, "../../evil.txt", "bad") + assert response.status_code == HTTPStatus.BAD_REQUEST + + +def test_user_files_post_nested_dot_filename_blocked(client, user_authentication, utilities): + """Filenames with dot basename are rejected even inside nested paths.""" + auth = user_authentication.json + suffix = utilities.generate_random_hex_string8() + response = post_file(client, auth, f"folder_{suffix}/.hidden", "x") + assert response.status_code == HTTPStatus.BAD_REQUEST + + +# --------------------------------------------------------------------------- +# DELETE /user/files +# --------------------------------------------------------------------------- + @pytest.mark.parametrize("mandatory_field", ["filename"]) def test_user_files_delete_missing_mandatory_fields(client, user_authentication, mandatory_field): auth = user_authentication.json @@ -224,3 +393,157 @@ def test_user_files_delete_ok(client, user_authentication, utilities): assert not os.path.exists(path) finally: remove_if_exists(path) + + +def test_user_files_delete_directory(client, user_authentication, ut_user_files_dir, utilities): + """DELETE should recursively remove a directory.""" + auth = user_authentication.json + suffix = utilities.generate_random_hex_string8() + dir_name = f"{UT_PREFIX}deldir_{suffix}" + dir_path = os.path.join(ut_user_files_dir, dir_name) + sub_file = os.path.join(dir_path, "child.txt") + + try: + os.makedirs(dir_path, exist_ok=True) + with open(sub_file, "w") as f: + f.write("child") + + resp = delete_file(client, auth, dir_name) + assert resp.status_code == HTTPStatus.OK + assert not os.path.exists(dir_path) + finally: + remove_if_exists(dir_path) + + +def test_user_files_delete_path_traversal_blocked(client, user_authentication): + auth = user_authentication.json + response = delete_file(client, auth, "../../etc") + assert response.status_code == HTTPStatus.BAD_REQUEST + + +# --------------------------------------------------------------------------- +# PUT /user/files – move / rename +# --------------------------------------------------------------------------- + +def test_user_files_move_unauthorized(client, user_authentication): + auth = user_authentication.json + response = client.put( + USER_FILES_URL, + json={"user-id": auth["id"], "token": "bad", "source": "a.txt", "destination": "b.txt"}, + ) + assert response.status_code == HTTPStatus.UNAUTHORIZED + + +@pytest.mark.parametrize("missing", ["source", "destination"]) +def test_user_files_move_missing_fields(client, user_authentication, missing): + auth = user_authentication.json + body = {**auth_json_body(auth), "source": "a.txt", "destination": "b.txt"} + del body[missing] + response = client.put(USER_FILES_URL, json=body) + assert response.status_code == HTTPStatus.BAD_REQUEST + + +def test_user_files_move_source_not_found(client, user_authentication, utilities): + auth = user_authentication.json + suffix = utilities.generate_random_hex_string8() + response = move_file(client, auth, f"no_{suffix}.txt", f"new_{suffix}.txt") + assert response.status_code == HTTPStatus.NOT_FOUND + + +def test_user_files_move_rename_file(client, user_authentication, utilities): + auth = user_authentication.json + suffix = utilities.generate_random_hex_string8() + old_name = f"{UT_PREFIX}mv_old_{suffix}.txt" + new_name = f"{UT_PREFIX}mv_new_{suffix}.txt" + base = user_files_dir(auth["id"]) + + try: + post_file(client, auth, old_name, "moveme") + resp = move_file(client, auth, old_name, new_name) + assert resp.status_code == HTTPStatus.OK + data = resp.get_json() + assert data["name"] == new_name + assert not os.path.exists(os.path.join(base, old_name)) + assert os.path.isfile(os.path.join(base, new_name)) + finally: + remove_if_exists(os.path.join(base, old_name)) + remove_if_exists(os.path.join(base, new_name)) + + +def test_user_files_move_into_subfolder(client, user_authentication, utilities): + auth = user_authentication.json + suffix = utilities.generate_random_hex_string8() + file_name = f"{UT_PREFIX}mv_{suffix}.txt" + dest_dir = f"{UT_PREFIX}dest_{suffix}" + base = user_files_dir(auth["id"]) + + try: + post_file(client, auth, file_name, "content") + resp = move_file(client, auth, file_name, f"{dest_dir}/{file_name}") + assert resp.status_code == HTTPStatus.OK + assert os.path.isfile(os.path.join(base, dest_dir, file_name)) + assert not os.path.exists(os.path.join(base, file_name)) + finally: + remove_if_exists(os.path.join(base, file_name)) + remove_if_exists(os.path.join(base, dest_dir)) + + +def test_user_files_move_conflict_when_destination_exists(client, user_authentication, utilities): + auth = user_authentication.json + suffix = utilities.generate_random_hex_string8() + a = f"{UT_PREFIX}a_{suffix}.txt" + b = f"{UT_PREFIX}b_{suffix}.txt" + base = user_files_dir(auth["id"]) + + try: + post_file(client, auth, a, "aaa") + post_file(client, auth, b, "bbb") + resp = move_file(client, auth, a, b) + assert resp.status_code == HTTPStatus.CONFLICT + finally: + remove_if_exists(os.path.join(base, a)) + remove_if_exists(os.path.join(base, b)) + + +def test_user_files_move_path_traversal_blocked(client, user_authentication, utilities): + auth = user_authentication.json + suffix = utilities.generate_random_hex_string8() + name = f"{UT_PREFIX}tr_{suffix}.txt" + base = user_files_dir(auth["id"]) + + try: + post_file(client, auth, name, "x") + resp = move_file(client, auth, name, f"../../{name}") + assert resp.status_code == HTTPStatus.BAD_REQUEST + finally: + remove_if_exists(os.path.join(base, name)) + + +def test_user_files_move_empty_fields(client, user_authentication): + auth = user_authentication.json + resp = move_file(client, auth, "", "something.txt") + assert resp.status_code == HTTPStatus.BAD_REQUEST + resp2 = move_file(client, auth, "something.txt", "") + assert resp2.status_code == HTTPStatus.BAD_REQUEST + + +def test_user_files_move_directory(client, user_authentication, ut_user_files_dir, utilities): + """PUT should be able to rename/move directories too.""" + auth = user_authentication.json + suffix = utilities.generate_random_hex_string8() + old_dir = f"{UT_PREFIX}olddir_{suffix}" + new_dir = f"{UT_PREFIX}newdir_{suffix}" + + try: + os.makedirs(os.path.join(ut_user_files_dir, old_dir), exist_ok=True) + with open(os.path.join(ut_user_files_dir, old_dir, "child.txt"), "w") as f: + f.write("hi") + + resp = move_file(client, auth, old_dir, new_dir) + assert resp.status_code == HTTPStatus.OK + assert resp.get_json()["type"] == "directory" + assert os.path.isdir(os.path.join(ut_user_files_dir, new_dir)) + assert not os.path.exists(os.path.join(ut_user_files_dir, old_dir)) + finally: + remove_if_exists(os.path.join(ut_user_files_dir, old_dir)) + remove_if_exists(os.path.join(ut_user_files_dir, new_dir)) diff --git a/api/test/user_files_test_helpers.py b/api/test/user_files_test_helpers.py index 62d5181c..154d456a 100644 --- a/api/test/user_files_test_helpers.py +++ b/api/test/user_files_test_helpers.py @@ -1,4 +1,4 @@ -"""Shared helpers for user-files HTTP tests (UserFiles, UserFileContent).""" +"""Shared helpers for user-files HTTP tests (UserFiles, UserFileContent, UserFileFolder).""" import os import shutil @@ -6,6 +6,7 @@ USER_FILES_URL = "/user/files" USER_FILE_CONTENT_URL = "/user/files/content" +USER_FILE_FOLDER_URL = "/user/files/folder" UT_PREFIX = "ut_user_files_" @@ -42,6 +43,16 @@ def delete_file(client, auth_json, filename): return client.delete(USER_FILES_URL, json=body) +def move_file(client, auth_json, source, destination): + body = {**auth_json_body(auth_json), "source": source, "destination": destination} + return client.put(USER_FILES_URL, json=body) + + +def create_folder(client, auth_json, foldername): + body = {**auth_json_body(auth_json), "foldername": foldername} + return client.post(USER_FILE_FOLDER_URL, json=body) + + def get_content(client, auth_json, filename): qs = {**auth_query(auth_json), "filename": filename} return client.get(USER_FILE_CONTENT_URL, query_string=qs) @@ -59,6 +70,8 @@ def put_content(client, auth_json, filename, filecontent): def remove_if_exists(path): if os.path.isfile(path): os.remove(path) + elif os.path.isdir(path): + shutil.rmtree(path) def remove_user_files_dir_if_exists(user_id): diff --git a/app/src/app/Constants/constants.tsx b/app/src/app/Constants/constants.tsx index b847ce73..d13750f5 100644 --- a/app/src/app/Constants/constants.tsx +++ b/app/src/app/Constants/constants.tsx @@ -1,5 +1,5 @@ export const API_BASE_URL = 'http://localhost:5000' -export const BASIL_VERSION = '1.8.10' +export const BASIL_VERSION = '1.8.11' export const TESTING_FARM_COMPOSES_URL = 'https://api.dev.testing-farm.io/v0.1/composes' export const force_reload = true @@ -42,6 +42,7 @@ export const API_USER_APIS_ENDPOINT = '/user/apis' export const API_USER_PERMISSIONS_API_ENDPOINT = '/user/permissions/api' export const API_USER_PERMISSIONS_API_COPY_ENDPOINT = '/user/permissions/copy' export const API_USER_FILES_ENDPOINT = '/user/files' +export const API_USER_FILES_FOLDER_ENDPOINT = '/user/files/folder' export const API_USER_FILES_CONTENT_ENDPOINT = '/user/files/content' export const API_USER_RESET_PASSWORD_ENDPOINT = '/user/reset-password' export const API_USER_SIGNIN_ENDPOINT = '/user/signin' @@ -285,16 +286,20 @@ export const getFilenameFromFilepath = (filepath: string) => { return filepath.split(PATH_SEP).pop() } -export const loadUserFiles = (_auth, _setFiles, _filter = '') => { - // _set is a useState Set variable used to populate the useState +export const loadUserFiles = (_auth, _setFiles, _filter = '', _path = '', _recursive = false) => { if (!_auth.isLogged()) { return } - let url - url = API_BASE_URL + API_USER_FILES_ENDPOINT + let url = API_BASE_URL + API_USER_FILES_ENDPOINT url += '?user-id=' + _auth.userId url += '&token=' + _auth.token url += '&filter=' + (_filter ? _filter : '') + if (_path) { + url += '&path=' + encodeURIComponent(_path) + } + if (_recursive) { + url += '&recursive=true' + } fetch(url, { method: 'GET', headers: JSON_HEADER @@ -302,7 +307,7 @@ export const loadUserFiles = (_auth, _setFiles, _filter = '') => { .then((res) => res.json()) .then((data) => { for (let i = 0; i < data.length; i++) { - data[i]['filename'] = getFilenameFromFilepath(data[i]['filepath']) + data[i]['filename'] = data[i]['name'] || getFilenameFromFilepath(data[i]['filepath']) } _setFiles(data) }) @@ -311,6 +316,79 @@ export const loadUserFiles = (_auth, _setFiles, _filter = '') => { }) } +export const createUserFolder = (_auth, _foldername, _onSuccess, _onError) => { + const data = { + 'user-id': _auth.userId, + token: _auth.token, + foldername: _foldername + } + fetch(API_BASE_URL + API_USER_FILES_FOLDER_ENDPOINT, { + method: 'POST', + headers: JSON_HEADER, + body: JSON.stringify(data) + }) + .then((response) => { + if (!isHttpSuccessStatus(response.status)) { + response + .json() + .then((body) => _onError(body?.message || response.statusText)) + .catch(() => _onError(response.statusText)) + } else { + _onSuccess() + } + }) + .catch((err) => _onError(err.toString())) +} + +export const moveUserFile = (_auth, _source, _destination, _onSuccess, _onError) => { + const data = { + 'user-id': _auth.userId, + token: _auth.token, + source: _source, + destination: _destination + } + fetch(API_BASE_URL + API_USER_FILES_ENDPOINT, { + method: 'PUT', + headers: JSON_HEADER, + body: JSON.stringify(data) + }) + .then((response) => { + if (!isHttpSuccessStatus(response.status)) { + response + .json() + .then((body) => _onError(body?.message || response.statusText)) + .catch(() => _onError(response.statusText)) + } else { + _onSuccess() + } + }) + .catch((err) => _onError(err.toString())) +} + +export const deleteUserFile = (_auth, _filename, _onSuccess, _onError) => { + const data = { + 'user-id': _auth.userId, + token: _auth.token, + filename: _filename + } + fetch(API_BASE_URL + API_USER_FILES_ENDPOINT, { + method: 'DELETE', + headers: JSON_HEADER, + body: JSON.stringify(data) + }) + .then((response) => { + if (!isHttpSuccessStatus(response.status)) { + response + .json() + .then((body) => _onError(body?.message || response.statusText)) + .catch(() => _onError(response.statusText)) + } else { + _onSuccess() + } + }) + .catch((err) => _onError(err.toString())) +} + export const loadFileContent = (_auth, _filename, _setMessage, _setContent) => { if (!_filename) { return @@ -326,7 +404,7 @@ export const loadFileContent = (_auth, _filename, _setMessage, _setContent) => { let url = API_BASE_URL + API_USER_FILES_CONTENT_ENDPOINT url += '?user-id=' + _auth.userId url += '&token=' + _auth.token - url += '&filename=' + _filename + url += '&filename=' + encodeURIComponent(_filename) fetch(url) .then((res) => { if (!res.ok) { diff --git a/app/src/app/Dashboard/Form/APIForm.tsx b/app/src/app/Dashboard/Form/APIForm.tsx index 8d7c4834..18f09e3a 100644 --- a/app/src/app/Dashboard/Form/APIForm.tsx +++ b/app/src/app/Dashboard/Form/APIForm.tsx @@ -165,7 +165,7 @@ export const APIForm: React.FunctionComponent = ({ React.useEffect(() => { if (referenceSource == 'user-files' || implementationSource == 'user-files') { if (userFiles.length == 0) { - Constants.loadUserFiles(auth, setUserFiles) + Constants.loadUserFiles(auth, setUserFiles, '', '', true) } } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -485,7 +485,7 @@ export const APIForm: React.FunctionComponent = ({ > {userFiles.map((userFile, index) => ( - + ))} )} @@ -539,7 +539,7 @@ export const APIForm: React.FunctionComponent = ({ > {userFiles.map((userFile, index) => ( - + ))} )} diff --git a/app/src/app/Mapping/Form/DocumentForm.tsx b/app/src/app/Mapping/Form/DocumentForm.tsx index f8f1206f..bf1f8a59 100644 --- a/app/src/app/Mapping/Form/DocumentForm.tsx +++ b/app/src/app/Mapping/Form/DocumentForm.tsx @@ -153,7 +153,7 @@ export const DocumentForm: React.FunctionComponent = ({ React.useEffect(() => { if (documentSource == 'user-files') { if (userFiles.length == 0) { - Constants.loadUserFiles(auth, setUserFiles) + Constants.loadUserFiles(auth, setUserFiles, '', '', true) } } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -515,7 +515,7 @@ export const DocumentForm: React.FunctionComponent = ({ > {userFiles.map((userFile, index) => ( - + ))} )} diff --git a/app/src/app/Mapping/Form/TestCaseForm.tsx b/app/src/app/Mapping/Form/TestCaseForm.tsx index d94fbc9f..4945f9d9 100644 --- a/app/src/app/Mapping/Form/TestCaseForm.tsx +++ b/app/src/app/Mapping/Form/TestCaseForm.tsx @@ -155,7 +155,7 @@ export const TestCaseForm: React.FunctionComponent = ({ React.useEffect(() => { if (implementationSource == 'user-files') { if (userFiles.length == 0) { - Constants.loadUserFiles(auth, setUserFiles) + Constants.loadUserFiles(auth, setUserFiles, '', '', true) } } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -640,7 +640,7 @@ export const TestCaseForm: React.FunctionComponent = ({ > {userFiles.map((userFile, index) => ( - + ))} {validatedImplementationFilePath !== 'success' && ( diff --git a/app/src/app/Mapping/Form/TestRunConfigForm.tsx b/app/src/app/Mapping/Form/TestRunConfigForm.tsx index 8d062eb5..103faf4a 100644 --- a/app/src/app/Mapping/Form/TestRunConfigForm.tsx +++ b/app/src/app/Mapping/Form/TestRunConfigForm.tsx @@ -233,7 +233,7 @@ export const TestRunConfigForm: React.FunctionComponent setTestingFarmUrlValue(testRunConfig?.url ?? '') } else if (testRunConfig?.plugin == Constants.LAVA_plugin) { if (userFiles.length == 0) { - Constants.loadUserFiles(auth, setUserFiles, '.yaml') + Constants.loadUserFiles(auth, setUserFiles, '.yaml', '', true) } setLavaPrivateTokenValue(testRunConfig?.private_token ?? '') setLavaUrlValue(testRunConfig?.url ?? '') @@ -429,7 +429,7 @@ export const TestRunConfigForm: React.FunctionComponent } if (value == Constants.LAVA_plugin) { if (userFiles.length == 0) { - Constants.loadUserFiles(auth, setUserFiles, '.yaml') + Constants.loadUserFiles(auth, setUserFiles, '.yaml', '', true) } } set_test_run_config_forked() @@ -903,7 +903,7 @@ export const TestRunConfigForm: React.FunctionComponent > {userFiles.map((userFile, index) => ( - + ))} diff --git a/app/src/app/Mapping/Import/TestCaseImport.tsx b/app/src/app/Mapping/Import/TestCaseImport.tsx index 2c5f3b8a..c5489e08 100644 --- a/app/src/app/Mapping/Import/TestCaseImport.tsx +++ b/app/src/app/Mapping/Import/TestCaseImport.tsx @@ -108,7 +108,7 @@ export const TestCaseImport: React.FunctionComponent = ({ l setCurrentView(SELECT_USER_FILE_VIEW) } if (userFiles.length == 0) { - Constants.loadUserFiles(auth, setUserFiles) + Constants.loadUserFiles(auth, setUserFiles, '', '', true) } const onKeyDown = (e: KeyboardEvent) => { @@ -401,7 +401,11 @@ export const TestCaseImport: React.FunctionComponent = ({ l > {userFiles.map((userFile, index) => ( - + ))} @@ -411,7 +415,7 @@ export const TestCaseImport: React.FunctionComponent = ({ l diff --git a/app/src/app/UserFiles/Form/UserFilesAddForm.tsx b/app/src/app/UserFiles/Form/UserFilesAddForm.tsx index 16a4a58f..463fbbc5 100644 --- a/app/src/app/UserFiles/Form/UserFilesAddForm.tsx +++ b/app/src/app/UserFiles/Form/UserFilesAddForm.tsx @@ -11,6 +11,9 @@ export interface UserFilesAddFormProps { fileContent setFileName setFileContent + currentPath: string + loadFiles: () => void + closeModal: () => void } export const UserFilesAddForm: React.FunctionComponent = ({ @@ -19,12 +22,13 @@ export const UserFilesAddForm: React.FunctionComponent = fileName, fileContent, setFileName, - setFileContent + setFileContent, + currentPath, + loadFiles, + closeModal }: UserFilesAddFormProps) => { const auth = useAuth() const [messageValue, setMessageValue] = React.useState('') - - // File Upload const [isLoading, setIsLoading] = React.useState(false) /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -65,10 +69,12 @@ export const UserFilesAddForm: React.FunctionComponent = setModalSubmit('waiting') setMessageValue('') + const fullFilename = currentPath ? currentPath + '/' + fileName : fileName + const data = { 'user-id': auth.userId, token: auth.token, - filename: fileName, + filename: fullFilename, filecontent: fileContent } @@ -81,7 +87,8 @@ export const UserFilesAddForm: React.FunctionComponent = if (!Constants.isHttpSuccessStatus(response.status)) { setMessageValue(response.statusText) } else { - window.location.reload() + loadFiles() + closeModal() } }) .catch((err) => { @@ -91,6 +98,11 @@ export const UserFilesAddForm: React.FunctionComponent = return (
+ {currentPath && ( + + Uploading to: {currentPath}/ + + )} void + closeModal: () => void +} + +export const UserFilesCreateFolderForm: React.FunctionComponent = ({ + modalFormSubmitState = 'waiting', + setModalSubmit, + currentPath, + loadFiles, + closeModal +}: UserFilesCreateFolderFormProps) => { + const auth = useAuth() + const [folderName, setFolderName] = React.useState('') + const [messageValue, setMessageValue] = React.useState('') + + React.useEffect(() => { + if (modalFormSubmitState == 'submitted') { + handleSubmit() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [modalFormSubmitState]) + + const handleSubmit = () => { + setModalSubmit('waiting') + setMessageValue('') + + if (!folderName.trim()) { + setMessageValue('Folder name is required') + return + } + + const fullPath = currentPath ? currentPath + '/' + folderName : folderName + + Constants.createUserFolder( + auth, + fullPath, + () => { + loadFiles() + closeModal() + }, + (err) => setMessageValue(err) + ) + } + + return ( + + {currentPath && ( + + Creating folder in: {currentPath}/ + + )} + + setFolderName(value)} + placeholder='Enter folder name' + /> + + {messageValue ? ( + + {messageValue} + + ) : ( + '' + )} + + ) +} diff --git a/app/src/app/UserFiles/Form/UserFilesDeleteConfirm.tsx b/app/src/app/UserFiles/Form/UserFilesDeleteConfirm.tsx new file mode 100644 index 00000000..520ca6d9 --- /dev/null +++ b/app/src/app/UserFiles/Form/UserFilesDeleteConfirm.tsx @@ -0,0 +1,63 @@ +import React from 'react' +import * as Constants from '../../Constants/constants' +import { Hint, HintBody } from '@patternfly/react-core' +import { useAuth } from '../../User/AuthProvider' + +export interface UserFilesDeleteConfirmProps { + modalFormSubmitState + setModalSubmit + modalRelativePath + modalFileName + loadFiles: () => void + closeModal: () => void +} + +export const UserFilesDeleteConfirm: React.FunctionComponent = ({ + modalFormSubmitState = 'waiting', + setModalSubmit, + modalRelativePath, + modalFileName, + loadFiles, + closeModal +}: UserFilesDeleteConfirmProps) => { + const auth = useAuth() + const [messageValue, setMessageValue] = React.useState('') + + React.useEffect(() => { + if (modalFormSubmitState == 'submitted') { + handleSubmit() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [modalFormSubmitState]) + + const handleSubmit = () => { + setModalSubmit('waiting') + setMessageValue('') + + Constants.deleteUserFile( + auth, + modalRelativePath.current, + () => { + loadFiles() + closeModal() + }, + (err) => setMessageValue(err) + ) + } + + return ( +
+

+ Are you sure you want to delete {modalFileName.current}? +

+

This action cannot be undone. If this is a folder, all contents will be permanently deleted.

+ {messageValue ? ( + + {messageValue} + + ) : ( + '' + )} +
+ ) +} diff --git a/app/src/app/UserFiles/Form/UserFilesMoveForm.tsx b/app/src/app/UserFiles/Form/UserFilesMoveForm.tsx new file mode 100644 index 00000000..253c24d2 --- /dev/null +++ b/app/src/app/UserFiles/Form/UserFilesMoveForm.tsx @@ -0,0 +1,87 @@ +import React from 'react' +import * as Constants from '../../Constants/constants' +import { Form, FormGroup, Hint, HintBody, TextInput } from '@patternfly/react-core' +import { useAuth } from '../../User/AuthProvider' + +export interface UserFilesMoveFormProps { + modalFormSubmitState + setModalSubmit + modalRelativePath + modalFileName + loadFiles: () => void + closeModal: () => void +} + +export const UserFilesMoveForm: React.FunctionComponent = ({ + modalFormSubmitState = 'waiting', + setModalSubmit, + modalRelativePath, + modalFileName, + loadFiles, + closeModal +}: UserFilesMoveFormProps) => { + const auth = useAuth() + const [destinationFolder, setDestinationFolder] = React.useState('') + const [messageValue, setMessageValue] = React.useState('') + + React.useEffect(() => { + if (modalFormSubmitState == 'submitted') { + handleSubmit() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [modalFormSubmitState]) + + const handleSubmit = () => { + setModalSubmit('waiting') + setMessageValue('') + + const sourcePath = modalRelativePath.current + const fileName = modalFileName.current + const destination = destinationFolder.trim() ? destinationFolder.trim() + '/' + fileName : fileName + + if (destination === sourcePath) { + setMessageValue('Destination is the same as the current location') + return + } + + Constants.moveUserFile( + auth, + sourcePath, + destination, + () => { + loadFiles() + closeModal() + }, + (err) => setMessageValue(err) + ) + } + + return ( +
+ + + Moving: {modalRelativePath.current} + + + + setDestinationFolder(value)} + placeholder='e.g. folder/subfolder (empty for root)' + /> + + + Leave empty to move to root. Use "/" to separate nested folders. + + {messageValue ? ( + + {messageValue} + + ) : ( + '' + )} +
+ ) +} diff --git a/app/src/app/UserFiles/Form/UserFilesRenameForm.tsx b/app/src/app/UserFiles/Form/UserFilesRenameForm.tsx new file mode 100644 index 00000000..d748fc77 --- /dev/null +++ b/app/src/app/UserFiles/Form/UserFilesRenameForm.tsx @@ -0,0 +1,94 @@ +import React from 'react' +import * as Constants from '../../Constants/constants' +import { Form, FormGroup, Hint, HintBody, TextInput } from '@patternfly/react-core' +import { useAuth } from '../../User/AuthProvider' + +export interface UserFilesRenameFormProps { + modalFormSubmitState + setModalSubmit + modalRelativePath + modalFileName + loadFiles: () => void + closeModal: () => void +} + +export const UserFilesRenameForm: React.FunctionComponent = ({ + modalFormSubmitState = 'waiting', + setModalSubmit, + modalRelativePath, + modalFileName, + loadFiles, + closeModal +}: UserFilesRenameFormProps) => { + const auth = useAuth() + const [newName, setNewName] = React.useState('') + const [messageValue, setMessageValue] = React.useState('') + + React.useEffect(() => { + setNewName(modalFileName.current || '') + }, [modalFileName]) + + React.useEffect(() => { + if (modalFormSubmitState == 'submitted') { + handleSubmit() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [modalFormSubmitState]) + + const handleSubmit = () => { + setModalSubmit('waiting') + setMessageValue('') + + if (!newName.trim()) { + setMessageValue('Name is required') + return + } + + const sourcePath = modalRelativePath.current + const parentDir = sourcePath.includes('/') ? sourcePath.substring(0, sourcePath.lastIndexOf('/')) : '' + const destination = parentDir ? parentDir + '/' + newName : newName + + if (destination === sourcePath) { + setMessageValue('New name is the same as the current name') + return + } + + Constants.moveUserFile( + auth, + sourcePath, + destination, + () => { + loadFiles() + closeModal() + }, + (err) => setMessageValue(err) + ) + } + + return ( +
+ + + Renaming: {modalFileName.current} + + + + setNewName(value)} + placeholder='Enter new name' + /> + + {messageValue ? ( + + {messageValue} + + ) : ( + '' + )} +
+ ) +} diff --git a/app/src/app/UserFiles/Menu/UserFilesMenuKebab.tsx b/app/src/app/UserFiles/Menu/UserFilesMenuKebab.tsx index d1409e58..01f1f5ec 100644 --- a/app/src/app/UserFiles/Menu/UserFilesMenuKebab.tsx +++ b/app/src/app/UserFiles/Menu/UserFilesMenuKebab.tsx @@ -1,12 +1,12 @@ import React from 'react' import { Dropdown, DropdownItem, DropdownList, MenuToggle, MenuToggleElement } from '@patternfly/react-core' -import * as Constants from '../../Constants/constants' import { useAuth } from '../../User/AuthProvider' import EllipsisVIcon from '@patternfly/react-icons/dist/esm/icons/ellipsis-v-icon' export interface UserFilesMenuKebabProps { modalAction modalFileName + modalRelativePath userFile setModalShowState } @@ -15,6 +15,7 @@ export const UserFilesMenuKebab: React.FunctionComponent { const auth = useAuth() @@ -28,35 +29,14 @@ export const UserFilesMenuKebab: React.FunctionComponent { - modalAction.current = 'edit' - modalFileName.current = _filename + const openModal = (action: string) => { + modalAction.current = action + modalFileName.current = userFile.name + modalRelativePath.current = userFile.relative_path setModalShowState(true) } - const deleteUserFile = () => { - const data = { - 'user-id': auth.userId, // The one that request the change - token: auth.token, // The one that request the change - filename: userFile.filename // The one that will be changed - } - - fetch(Constants.API_BASE_URL + Constants.API_USER_FILES_ENDPOINT, { - method: 'DELETE', - headers: Constants.JSON_HEADER, - body: JSON.stringify(data) - }) - .then((response) => { - if (!Constants.isHttpSuccessStatus(response.status)) { - console.log(response.status) - } else { - location.reload() - } - }) - .catch((err) => { - console.log(err) - }) - } + const isDirectory = userFile.type === 'directory' return ( {auth.isLogged() && !auth.isGuest() ? ( <> + {!isDirectory && ( + openModal('edit')} + > + Edit + + )} deleteUserFile()} + id={'btn-menu-user-file-rename-' + userFile.index} + key={'action user file rename ' + userFile.index} + onClick={() => openModal('rename')} > - Delete + Rename + + openModal('move')} + > + Move editUserFile(userFile.filename)} + id={'btn-menu-user-file-delete-' + userFile.index} + key={'action user file delete ' + userFile.index} + onClick={() => openModal('delete')} + style={{ color: '#c9190b' }} > - Edit + Delete ) : ( diff --git a/app/src/app/UserFiles/Modal/UserFilesModal.tsx b/app/src/app/UserFiles/Modal/UserFilesModal.tsx index 7ebc5cd4..75cf6f07 100644 --- a/app/src/app/UserFiles/Modal/UserFilesModal.tsx +++ b/app/src/app/UserFiles/Modal/UserFilesModal.tsx @@ -2,20 +2,39 @@ import React from 'react' import { Button, Modal, ModalVariant } from '@patternfly/react-core' import { UserFilesAddForm } from '../Form/UserFilesAddForm' import { UserFilesEditForm } from '../Form/UserFilesEditForm' +import { UserFilesCreateFolderForm } from '../Form/UserFilesCreateFolderForm' +import { UserFilesRenameForm } from '../Form/UserFilesRenameForm' +import { UserFilesMoveForm } from '../Form/UserFilesMoveForm' +import { UserFilesDeleteConfirm } from '../Form/UserFilesDeleteConfirm' import * as Constants from '@app/Constants/constants' export interface UserFilesModalProps { modalAction modalFileName + modalRelativePath modalShowState setModalShowState + currentPath: string + loadFiles: () => void +} + +const MODAL_TITLES = { + add: 'Add a new File', + edit: 'Edit File', + 'create-folder': 'Create Folder', + rename: 'Rename', + move: 'Move', + delete: 'Delete' } export const UserFilesModal: React.FunctionComponent = ({ modalAction, modalFileName, + modalRelativePath, modalShowState = false, - setModalShowState + setModalShowState, + currentPath, + loadFiles }: UserFilesModalProps) => { const [isModalOpen, setIsModalOpen] = React.useState(false) const [modalFormSubmitState, setModalFormSubmitState] = React.useState('waiting') @@ -30,12 +49,95 @@ export const UserFilesModal: React.FunctionComponent = ({ const new_state = !modalShowState setModalShowState(new_state) setIsModalOpen(new_state) + if (!new_state) { + setFileName('') + setFileContent('') + setModalFormSubmitState('waiting') + } } React.useEffect(() => { setIsModalOpen(modalShowState) }, [modalShowState]) + const action = modalAction.current + const title = MODAL_TITLES[action] || 'User File' + + const confirmLabel = action === 'delete' ? 'Delete' : 'Confirm' + const confirmVariant = action === 'delete' ? 'danger' : 'primary' + + const renderForm = () => { + switch (action) { + case 'add': + return ( + + ) + case 'edit': + return ( + + ) + case 'create-folder': + return ( + + ) + case 'rename': + return ( + + ) + case 'move': + return ( + + ) + case 'delete': + return ( + + ) + default: + return null + } + } + return ( = ({ aria-label='user files modal' tabIndex={0} variant={ModalVariant.large} - title='Add a new File' + title={title} description={''} isOpen={isModalOpen} onClose={handleModalToggle} actions={[ , - ]} > - {modalAction.current == 'add' ? ( - - ) : ( - - )} + {renderForm()} ) diff --git a/app/src/app/UserFiles/UserFiles.tsx b/app/src/app/UserFiles/UserFiles.tsx index d84441f2..e656d3f5 100644 --- a/app/src/app/UserFiles/UserFiles.tsx +++ b/app/src/app/UserFiles/UserFiles.tsx @@ -1,30 +1,51 @@ import * as React from 'react' import * as Constants from '../Constants/constants' -import { Button, Card, CardBody, Flex, FlexItem, PageSection, Title } from '@patternfly/react-core' +import { Breadcrumb, BreadcrumbItem, Button, Card, CardBody, Flex, FlexItem, PageSection, Title } from '@patternfly/react-core' import { UserFilesListingTable } from './UserFilesListing' import { UserFilesModal } from './Modal/UserFilesModal' import { useAuth } from '../User/AuthProvider' +import FolderIcon from '@patternfly/react-icons/dist/esm/icons/folder-open-icon' const UserFiles: React.FunctionComponent = () => { const auth = useAuth() const modal_action = React.useRef('add') - const modal_filename = React.useRef('') // to be used for edit form + const modal_filename = React.useRef('') + const modal_relative_path = React.useRef('') + const [currentPath, setCurrentPath] = React.useState('') const [userFiles, setUserFiles] = React.useState([]) const [modalShowState, setModalShowState] = React.useState(false) + const loadFiles = React.useCallback(() => { + Constants.loadUserFiles(auth, setUserFiles, '', currentPath) + }, [auth, currentPath]) + React.useEffect(() => { - Constants.loadUserFiles(auth, setUserFiles) + loadFiles() // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, [currentPath]) const addFile = () => { modal_action.current = 'add' modal_filename.current = '' + modal_relative_path.current = '' + setModalShowState(true) + } + + const createFolder = () => { + modal_action.current = 'create-folder' + modal_filename.current = '' + modal_relative_path.current = '' setModalShowState(true) } + const navigateTo = (path: string) => { + setCurrentPath(path) + } + + const breadcrumbSegments = currentPath ? currentPath.split('/').filter(Boolean) : [] + return ( @@ -38,28 +59,72 @@ const UserFiles: React.FunctionComponent = () => { {!auth.isGuest() ? ( - + <> + + + ) : ( '' )} + + + { + e.preventDefault() + navigateTo('') + }} + isActive={currentPath === ''} + > + Home + + {breadcrumbSegments.map((segment, idx) => { + const partialPath = breadcrumbSegments.slice(0, idx + 1).join('/') + const isLast = idx === breadcrumbSegments.length - 1 + return ( + { + e.preventDefault() + if (!isLast) navigateTo(partialPath) + }} + isActive={isLast} + > + {segment} + + ) + })} + + ) diff --git a/app/src/app/UserFiles/UserFilesListing.tsx b/app/src/app/UserFiles/UserFilesListing.tsx index 1fae3bfd..9e4fa155 100644 --- a/app/src/app/UserFiles/UserFilesListing.tsx +++ b/app/src/app/UserFiles/UserFilesListing.tsx @@ -1,33 +1,70 @@ import * as React from 'react' import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table' import { UserFilesMenuKebab } from './Menu/UserFilesMenuKebab' +import FolderIcon from '@patternfly/react-icons/dist/esm/icons/folder-icon' +import FileIcon from '@patternfly/react-icons/dist/esm/icons/file-icon' +import { Button } from '@patternfly/react-core' export interface UserFilesListingTableProps { modalAction modalFileName + modalRelativePath userFiles setModalShowState + currentPath: string + navigateTo: (path: string) => void } const UserFilesListingTable: React.FunctionComponent = ({ userFiles, modalAction, modalFileName, - setModalShowState + modalRelativePath, + setModalShowState, + currentPath, + navigateTo }: UserFilesListingTableProps) => { const getTable = () => { - if (userFiles.length == 0) { - return '' + if (userFiles.length === 0) { + return ( + + + + This folder is empty + + + + ) } else { return userFiles.map((userFile) => ( - {userFile.filename} + + {userFile.type === 'directory' ? : } + + + {userFile.type === 'directory' ? ( + + ) : ( + userFile.name + )} + {userFile.updated_at} @@ -43,6 +80,7 @@ const UserFilesListingTable: React.FunctionComponent + diff --git a/pyproject.toml b/pyproject.toml index c7d8f4b1..fdd265f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] # Project inception: 2023-10-02 name = "BASIL" -version = "1.8.10" +version = "1.8.11" description = "open source software quality management tool" authors = [ {name = "Luigi Pellecchia", email = "lpellecc@redhat.com"}, From 6840aba884fb23d0b4bcf91665303cecd3d80f5b Mon Sep 17 00:00:00 2001 From: Luigi Pellecchia Date: Thu, 23 Apr 2026 11:50:26 +0200 Subject: [PATCH 2/5] Add e2e test for user files Signed-off-by: Luigi Pellecchia --- app/cypress/e2e/user_files.cy.js | 139 +++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 app/cypress/e2e/user_files.cy.js diff --git a/app/cypress/e2e/user_files.cy.js b/app/cypress/e2e/user_files.cy.js new file mode 100644 index 00000000..dc1b7226 --- /dev/null +++ b/app/cypress/e2e/user_files.cy.js @@ -0,0 +1,139 @@ +/// + +import '../support/e2e.js' +import const_data from '../fixtures/consts.json' + +const UNIQUE = Date.now().toString() + +describe('User Files - Nested Folder Support', () => { + before(() => { + cy.login_admin() + }) + + it('Navigate to User Files page', () => { + cy.get('#nav-item-user-files').click() + cy.wait(const_data.long_wait) + cy.url().should('include', '/user-files') + cy.get('#table-user-files').should('exist') + }) + + it('Create a folder', () => { + cy.get('#btn-create-user-folder').click() + cy.wait(const_data.fast_wait) + cy.get('#input-create-folder-name').type('test_folder_' + UNIQUE) + cy.get('#btn-user-file-modal-confirm').click() + cy.wait(const_data.long_wait) + cy.get('#table-user-files').find('tbody').contains('test_folder_' + UNIQUE).should('exist') + }) + + it('Navigate into folder via click', () => { + cy.get('#table-user-files').find('tbody').contains('test_folder_' + UNIQUE).click() + cy.wait(const_data.mid_wait) + cy.get('#breadcrumb-0').should('contain.text', 'test_folder_' + UNIQUE) + cy.get('#table-user-files').find('tbody').should('contain.text', 'empty') + }) + + it('Upload file inside nested folder', () => { + cy.get('#btn-add-user-file').click() + cy.wait(const_data.fast_wait) + cy.get('#user-file-upload-filename').type('nested_file_' + UNIQUE + '.yaml') + cy.get('#user-file-upload').find('textarea').type('key: value', { force: true }) + cy.get('#btn-user-file-modal-confirm').click() + cy.wait(const_data.long_wait) + cy.get('#table-user-files').find('tbody').contains('nested_file_' + UNIQUE + '.yaml').should('exist') + }) + + it('Navigate back to root via breadcrumb', () => { + cy.get('#breadcrumb-root').click() + cy.wait(const_data.mid_wait) + cy.get('#table-user-files').find('tbody').contains('test_folder_' + UNIQUE).should('exist') + }) + + it('Create a subfolder for move test', () => { + cy.get('#btn-create-user-folder').click() + cy.wait(const_data.fast_wait) + cy.get('#input-create-folder-name').type('move_dest_' + UNIQUE) + cy.get('#btn-user-file-modal-confirm').click() + cy.wait(const_data.long_wait) + cy.get('#table-user-files').find('tbody').contains('move_dest_' + UNIQUE).should('exist') + }) + + it('Upload a file at root for move test', () => { + cy.get('#btn-add-user-file').click() + cy.wait(const_data.fast_wait) + cy.get('#user-file-upload-filename').type('movable_' + UNIQUE + '.txt') + cy.get('#user-file-upload').find('textarea').type('to be moved', { force: true }) + cy.get('#btn-user-file-modal-confirm').click() + cy.wait(const_data.long_wait) + cy.get('#table-user-files').find('tbody').contains('movable_' + UNIQUE + '.txt').should('exist') + }) + + it('Move file into folder', () => { + cy.get('#table-user-files') + .find('tbody') + .contains('movable_' + UNIQUE + '.txt') + .parents('tr') + .find('button[aria-label="kebab dropdown toggle"]') + .click() + cy.wait(const_data.fast_wait) + cy.get('[id^="btn-menu-user-file-move-"]').click() + cy.wait(const_data.fast_wait) + cy.get('#input-move-destination').type('move_dest_' + UNIQUE) + cy.get('#btn-user-file-modal-confirm').click() + cy.wait(const_data.long_wait) + cy.get('#table-user-files').find('tbody').contains('movable_' + UNIQUE + '.txt').should('not.exist') + + cy.get('#table-user-files').find('tbody').contains('move_dest_' + UNIQUE).click() + cy.wait(const_data.mid_wait) + cy.get('#table-user-files').find('tbody').contains('movable_' + UNIQUE + '.txt').should('exist') + cy.get('#breadcrumb-root').click() + cy.wait(const_data.mid_wait) + }) + + it('Rename a folder', () => { + cy.get('#table-user-files') + .find('tbody') + .contains('move_dest_' + UNIQUE) + .parents('tr') + .find('button[aria-label="kebab dropdown toggle"]') + .click() + cy.wait(const_data.fast_wait) + cy.get('[id^="btn-menu-user-file-rename-"]').click() + cy.wait(const_data.fast_wait) + cy.get('#input-rename-name').clear().type('renamed_' + UNIQUE) + cy.get('#btn-user-file-modal-confirm').click() + cy.wait(const_data.long_wait) + cy.get('#table-user-files').find('tbody').contains('renamed_' + UNIQUE).should('exist') + cy.get('#table-user-files').find('tbody').contains('move_dest_' + UNIQUE).should('not.exist') + }) + + it('Delete a folder', () => { + cy.get('#table-user-files') + .find('tbody') + .contains('renamed_' + UNIQUE) + .parents('tr') + .find('button[aria-label="kebab dropdown toggle"]') + .click() + cy.wait(const_data.fast_wait) + cy.get('[id^="btn-menu-user-file-delete-"]').click() + cy.wait(const_data.fast_wait) + cy.get('#btn-user-file-modal-confirm').click() + cy.wait(const_data.long_wait) + cy.get('#table-user-files').find('tbody').contains('renamed_' + UNIQUE).should('not.exist') + }) + + it('Delete test folder', () => { + cy.get('#table-user-files') + .find('tbody') + .contains('test_folder_' + UNIQUE) + .parents('tr') + .find('button[aria-label="kebab dropdown toggle"]') + .click() + cy.wait(const_data.fast_wait) + cy.get('[id^="btn-menu-user-file-delete-"]').click() + cy.wait(const_data.fast_wait) + cy.get('#btn-user-file-modal-confirm').click() + cy.wait(const_data.long_wait) + cy.get('#table-user-files').find('tbody').contains('test_folder_' + UNIQUE).should('not.exist') + }) +}) From df1251369c6ca434800a8e558d2dfb5419366fc2 Mon Sep 17 00:00:00 2001 From: Luigi Pellecchia Date: Thu, 23 Apr 2026 15:00:48 +0200 Subject: [PATCH 3/5] E2E fix (app/cypress/support/commands.js): - Fix `add_test_case_to_user_files` command: update button selector from `#btn-user-file-add-confirm` to `#btn-user-file-modal-confirm` to match the renamed confirm button id in the refactored `UserFilesModal` component Signed-off-by: Luigi Pellecchia --- app/cypress/support/commands.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/cypress/support/commands.js b/app/cypress/support/commands.js index c2843028..04bbf9fb 100644 --- a/app/cypress/support/commands.js +++ b/app/cypress/support/commands.js @@ -388,7 +388,7 @@ export function registerCommands() { cy.get('#btn-add-user-file').click() cy.wait(const_data.long_wait) cy.get('#user-file-upload-browse-button').selectFile(import_file, { action: 'drag-drop' }) - cy.get('#btn-user-file-add-confirm').click() + cy.get('#btn-user-file-modal-confirm').click() cy.wait(const_data.long_wait) }) From 6a76e7b38b00a7f4a934b2d18850c4d1e1cee124 Mon Sep 17 00:00:00 2001 From: Luigi Pellecchia Date: Thu, 23 Apr 2026 17:02:44 +0200 Subject: [PATCH 4/5] Fix e2e test for user files Signed-off-by: Luigi Pellecchia --- app/cypress/e2e/user_files.cy.js | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/app/cypress/e2e/user_files.cy.js b/app/cypress/e2e/user_files.cy.js index dc1b7226..557f616d 100644 --- a/app/cypress/e2e/user_files.cy.js +++ b/app/cypress/e2e/user_files.cy.js @@ -27,7 +27,7 @@ describe('User Files - Nested Folder Support', () => { }) it('Navigate into folder via click', () => { - cy.get('#table-user-files').find('tbody').contains('test_folder_' + UNIQUE).click() + cy.get('#table-user-files').find('tbody').contains('test_folder_' + UNIQUE).click({ force: true }) cy.wait(const_data.mid_wait) cy.get('#breadcrumb-0').should('contain.text', 'test_folder_' + UNIQUE) cy.get('#table-user-files').find('tbody').should('contain.text', 'empty') @@ -36,15 +36,22 @@ describe('User Files - Nested Folder Support', () => { it('Upload file inside nested folder', () => { cy.get('#btn-add-user-file').click() cy.wait(const_data.fast_wait) - cy.get('#user-file-upload-filename').type('nested_file_' + UNIQUE + '.yaml') - cy.get('#user-file-upload').find('textarea').type('key: value', { force: true }) + cy.get('.pf-v5-c-file-upload input[type="file"]').selectFile( + { + contents: Cypress.Buffer.from('key: value'), + fileName: 'nested_file_' + UNIQUE + '.yaml', + mimeType: 'text/yaml' + }, + { force: true } + ) + cy.wait(const_data.mid_wait) cy.get('#btn-user-file-modal-confirm').click() cy.wait(const_data.long_wait) cy.get('#table-user-files').find('tbody').contains('nested_file_' + UNIQUE + '.yaml').should('exist') }) it('Navigate back to root via breadcrumb', () => { - cy.get('#breadcrumb-root').click() + cy.get('#breadcrumb-root').click({ force: true }) cy.wait(const_data.mid_wait) cy.get('#table-user-files').find('tbody').contains('test_folder_' + UNIQUE).should('exist') }) @@ -61,8 +68,15 @@ describe('User Files - Nested Folder Support', () => { it('Upload a file at root for move test', () => { cy.get('#btn-add-user-file').click() cy.wait(const_data.fast_wait) - cy.get('#user-file-upload-filename').type('movable_' + UNIQUE + '.txt') - cy.get('#user-file-upload').find('textarea').type('to be moved', { force: true }) + cy.get('.pf-v5-c-file-upload input[type="file"]').selectFile( + { + contents: Cypress.Buffer.from('to be moved'), + fileName: 'movable_' + UNIQUE + '.txt', + mimeType: 'text/plain' + }, + { force: true } + ) + cy.wait(const_data.mid_wait) cy.get('#btn-user-file-modal-confirm').click() cy.wait(const_data.long_wait) cy.get('#table-user-files').find('tbody').contains('movable_' + UNIQUE + '.txt').should('exist') @@ -83,10 +97,10 @@ describe('User Files - Nested Folder Support', () => { cy.wait(const_data.long_wait) cy.get('#table-user-files').find('tbody').contains('movable_' + UNIQUE + '.txt').should('not.exist') - cy.get('#table-user-files').find('tbody').contains('move_dest_' + UNIQUE).click() + cy.get('#table-user-files').find('tbody').contains('move_dest_' + UNIQUE).click({ force: true }) cy.wait(const_data.mid_wait) cy.get('#table-user-files').find('tbody').contains('movable_' + UNIQUE + '.txt').should('exist') - cy.get('#breadcrumb-root').click() + cy.get('#breadcrumb-root').click({ force: true }) cy.wait(const_data.mid_wait) }) From d1ad0c8d9f8ab0b17edf0923b8797df8cc0f085c Mon Sep 17 00:00:00 2001 From: Luigi Pellecchia Date: Fri, 24 Apr 2026 08:36:08 +0200 Subject: [PATCH 5/5] Fix user_files test removing test isolation Signed-off-by: Luigi Pellecchia --- app/cypress/e2e/user_files.cy.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/cypress/e2e/user_files.cy.js b/app/cypress/e2e/user_files.cy.js index 557f616d..2c7d2981 100644 --- a/app/cypress/e2e/user_files.cy.js +++ b/app/cypress/e2e/user_files.cy.js @@ -5,7 +5,7 @@ import const_data from '../fixtures/consts.json' const UNIQUE = Date.now().toString() -describe('User Files - Nested Folder Support', () => { +describe('User Files - Nested Folder Support', { testIsolation: false }, () => { before(() => { cy.login_admin() })
Name Updated at Actions