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/cypress/e2e/user_files.cy.js b/app/cypress/e2e/user_files.cy.js new file mode 100644 index 00000000..2c7d2981 --- /dev/null +++ b/app/cypress/e2e/user_files.cy.js @@ -0,0 +1,153 @@ +/// + +import '../support/e2e.js' +import const_data from '../fixtures/consts.json' + +const UNIQUE = Date.now().toString() + +describe('User Files - Nested Folder Support', { testIsolation: false }, () => { + 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({ 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') + }) + + it('Upload file inside nested folder', () => { + cy.get('#btn-add-user-file').click() + cy.wait(const_data.fast_wait) + 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({ force: true }) + 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('.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') + }) + + 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({ 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({ force: true }) + 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') + }) +}) 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) }) 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"},
Name Updated at Actions