diff --git a/.github/workflows/ci-vm-scripts.yml b/.github/workflows/ci-vm-scripts.yml new file mode 100644 index 000000000..128e9ee7c --- /dev/null +++ b/.github/workflows/ci-vm-scripts.yml @@ -0,0 +1,37 @@ +name: CI-VM scripts + +# Static analysis for the CI-VM runner scripts (the scripts that run on the +# throwaway test VMs). Runs only when those scripts change. shellcheck lints the +# bash runners; PSScriptAnalyzer lints the PowerShell startup script. + +on: + pull_request: + paths: + - 'install/ci-vm/**' + - '.github/workflows/ci-vm-scripts.yml' + push: + paths: + - 'install/ci-vm/**' + - '.github/workflows/ci-vm-scripts.yml' + +jobs: + shellcheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install shellcheck + run: sudo apt-get update && sudo apt-get install -y shellcheck + - name: shellcheck (bash runner scripts) + run: shellcheck --severity=error install/ci-vm/ci-linux/ci/runCI install/ci-vm/ci-linux/startup-script.sh + + psscriptanalyzer: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - name: PSScriptAnalyzer (startup-script.ps1) + shell: pwsh + run: | + Set-PSRepository PSGallery -InstallationPolicy Trusted + Install-Module -Name PSScriptAnalyzer -Force -Scope CurrentUser + $issues = Invoke-ScriptAnalyzer -Path install/ci-vm/ci-windows/startup-script.ps1 -Severity Error + if ($issues) { $issues | Format-Table -AutoSize; exit 1 } diff --git a/install/ci-vm/ci-linux/ci/runCI b/install/ci-vm/ci-linux/ci/runCI index 092d425e7..e5c2bcbc4 100644 --- a/install/ci-vm/ci-linux/ci/runCI +++ b/install/ci-vm/ci-linux/ci/runCI @@ -103,6 +103,27 @@ function executeCommand { fi } +# Send a build artifact to the server (best-effort: a failed upload must never +# abort the run, so this always returns 0). Reuses the reportURL/curl pattern. +function sendArtifact { + local artifactType="$1" + local filePath="$2" + if [ ! -f "${filePath}" ]; then + echo "Artifact ${artifactType}: no file at '${filePath}', skipping" >> "${logFile}" + return 0 + fi + echo "Uploading ${artifactType} artifact (${filePath})" >> "${logFile}" + # --fail surfaces HTTP >= 400 (e.g. 413 on an oversized coredump) as a + # curl error so failed uploads are visible in the log; still best-effort. + curl -s --fail -A "${userAgent}" --form "type=artifactupload" --form "artifact_type=${artifactType}" \ + --form "file=@${filePath}" "${reportURL}" >> "${logFile}" 2>&1 + local curlStatus=$? + if [ ${curlStatus} -ne 0 ]; then + echo "Artifact ${artifactType}: upload failed (curl exit ${curlStatus})" >> "${logFile}" + fi + return 0 +} + # Source variables . "$DIR/variables" @@ -125,7 +146,25 @@ if [ -e "${dstDir}/ccextractor" ]; then echo "=== End Version Info ===" >> "${logFile}" postStatus "testing" "Running tests" executeCommand cd ${suiteDstDir} - executeCommand ${tester} --debug --entries "${testFile}" --executable "ccextractor" --tempfolder "${tempFolder}" --timeout 600 --reportfolder "${reportFolder}" --resultfolder "${resultFolder}" --samplefolder "${sampleFolder}" --method Server --url "${reportURL}" + + # Enable core dumps and capture the test run's combined stdout/stderr so + # both can be uploaded as artifacts (the API serves them per run). + ulimit -c unlimited 2>/dev/null + echo "core.%p" | sudo tee /proc/sys/kernel/core_pattern >/dev/null 2>&1 + combinedLog="${reportFolder}/combined_stdout.log" + ${tester} --debug --entries "${testFile}" --executable "ccextractor" --tempfolder "${tempFolder}" --timeout 600 --reportfolder "${reportFolder}" --resultfolder "${resultFolder}" --samplefolder "${sampleFolder}" --method Server --url "${reportURL}" > "${combinedLog}" 2>&1 + testerStatus=$? + cat "${combinedLog}" >> "${logFile}" + + # Upload artifacts before any failure-halt so crash data survives. + sendArtifact "binary" "${dstDir}/ccextractor" + sendArtifact "combined_stdout" "${combinedLog}" + sendArtifact "coredump" "$(ls -1t core.* 2>/dev/null | head -n1)" + + if [ ${testerStatus} -ne 0 ]; then + haltAndCatchFire "" + fi + sendLogFile postStatus "completed" "Ran all tests" diff --git a/install/ci-vm/ci-linux/startup-script.sh b/install/ci-vm/ci-linux/startup-script.sh index d84fc3435..4401e741f 100644 --- a/install/ci-vm/ci-linux/startup-script.sh +++ b/install/ci-vm/ci-linux/startup-script.sh @@ -4,7 +4,7 @@ curl -L -O https://github.com/GoogleCloudPlatform/gcsfuse/releases/download/v3.2 dpkg --install gcsfuse_3.2.0_amd64.deb rm gcsfuse_3.2.0_amd64.deb -apt install gnupg ca-certificates +apt install -y gnupg ca-certificates gpg --homedir /tmp --no-default-keyring --keyring /usr/share/keyrings/mono-official-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys 3FA7E0328081BFF6A14DA29AA6A19B38D3D831EF echo "deb [signed-by=/usr/share/keyrings/mono-official-archive-keyring.gpg] https://download.mono-project.com/repo/ubuntu stable-focal main" | sudo tee /etc/apt/sources.list.d/mono-official-stable.list sudo apt update @@ -14,7 +14,8 @@ mkdir repository cd repository # Use gcsfuse and import required files -mkdir temp TestFiles TestResults vm_data reports +# TempFiles is used by the tester (--tempfolder) and must exist +mkdir temp TestFiles TestResults TempFiles vm_data reports gcs_bucket=$(curl http://metadata/computeMetadata/v1/instance/attributes/bucket -H "Metadata-Flavor: Google") @@ -31,6 +32,9 @@ mount vm_data mount TestFiles mount TestResults +# Give gcsfuse mounts time to become ready +sleep 10 + cp temp/* ./ chmod +x bootstrap diff --git a/install/ci-vm/ci-windows/ci/runCI.bat b/install/ci-vm/ci-windows/ci/runCI.bat index 34e359927..4a8bc1b8d 100644 --- a/install/ci-vm/ci-windows/ci/runCI.bat +++ b/install/ci-vm/ci-windows/ci/runCI.bat @@ -15,7 +15,10 @@ for /F %%R in ('curl http://metadata/computeMetadata/v1/instance/attributes/repo SET userAgent="CCX/CI_BOT" SET logFile="%reportFolder%/log.html" -call :postStatus "preparation" "Loaded variables, created log file and checking for CCExtractor build artifact" >> "%logFile%" +rem NB: no outer ">> %logFile%" here. postStatus already appends to %logFile% +rem internally; an outer redirect to the same file self-locks on Windows +rem (the inner append cannot open a file the outer redirect holds open). +call :postStatus "preparation" "Loaded variables, created log file and checking for CCExtractor build artifact" echo Checking for CCExtractor build artifact if EXIST "%dstDir%\ccextractorwinfull.exe" ( @@ -27,7 +30,21 @@ if EXIST "%dstDir%\ccextractorwinfull.exe" ( echo === End Version Info === >> "%logFile%" call :postStatus "testing" "Running tests" call :executeCommand cd %suiteDstDir% - call :executeCommand "%tester%" --debug True --entries "%testFile%" --executable "ccextractorwinfull.exe" --tempfolder "%tempFolder%" --timeout 600 --reportfolder "%reportFolder%" --resultfolder "%resultFolder%" --samplefolder "%sampleFolder%" --method Server --url "%reportURL%" + + rem Capture the test run's combined stdout/stderr for upload as an artifact. + rem NB: "||" instead of "if errorlevel 1" - it fires at runtime on ANY + rem nonzero exit code, including the negative NTSTATUS codes a crash + rem returns, e.g. 0xC0000005, and works inside this parenthesized block + rem where %ERRORLEVEL% would expand too early. + set "testerFailed=" + "%tester%" --debug True --entries "%testFile%" --executable "ccextractorwinfull.exe" --tempfolder "%tempFolder%" --timeout 600 --reportfolder "%reportFolder%" --resultfolder "%resultFolder%" --samplefolder "%sampleFolder%" --method Server --url "%reportURL%" > "%reportFolder%/combined_stdout.log" 2>&1 || set "testerFailed=1" + type "%reportFolder%/combined_stdout.log" >> "%logFile%" + + rem Upload artifacts (best-effort; never aborts the run). Windows skips coredump for v1. + call :sendArtifact "binary" "%dstDir%\ccextractorwinfull.exe" + call :sendArtifact "combined_stdout" "%reportFolder%/combined_stdout.log" + + if defined testerFailed call :haltAndCatchFire "" call :sendLogFile @@ -144,3 +161,21 @@ if !sl_attempt! LEQ %sl_max_retries% ( echo ERROR: Failed to upload log after %sl_max_retries% attempts >> "%logFile%" endlocal EXIT /B 1 + +rem Send a build artifact to the server (best-effort; never aborts the run) +:sendArtifact +setlocal +set "sa_type=%~1" +set "sa_file=%~2" +if NOT EXIST "%sa_file%" ( + echo Artifact %sa_type%: no file at "%sa_file%", skipping >> "%logFile%" + endlocal + EXIT /B 0 +) +echo Uploading %sa_type% artifact (%sa_file%) >> "%logFile%" +rem --fail makes HTTP >= 400 (e.g. 413 on an oversized file) a curl error so +rem the failure is at least visible in the log; the upload stays best-effort. +curl -s --fail -A "%userAgent%" --form "type=artifactupload" --form "artifact_type=%sa_type%" --form "file=@%sa_file%" "%reportURL%" >> "%logFile%" 2>&1 +if errorlevel 1 echo Artifact %sa_type%: upload failed with curl exit code %ERRORLEVEL% >> "%logFile%" +endlocal +EXIT /B 0 diff --git a/mod_api/routes/system.py b/mod_api/routes/system.py index 6a9cd7b92..363ffa112 100644 --- a/mod_api/routes/system.py +++ b/mod_api/routes/system.py @@ -1,8 +1,9 @@ """ -System, health, and queue routes. +System, health, queue, and artifact routes. GET /system/health Health check (unauthenticated) GET /system/queue Queue status — active + queued runs +GET /runs/{id}/artifacts Run artifacts from GCS + local storage """ import os @@ -10,16 +11,24 @@ from flask import g, jsonify, request from sqlalchemy import text +from sqlalchemy.orm import joinedload from mod_api import mod_api from mod_api.middleware.auth import require_scope from mod_api.middleware.error_handler import make_error_response -from mod_api.middleware.validation import validate_offset_pagination +from mod_api.middleware.validation import (validate_offset_pagination, + validate_path_id) from mod_api.models.api_token import Scope from mod_api.schemas.common import DATETIME_FORMAT -from mod_api.services.status import batch_get_run_data -from mod_api.utils import paginated_response -from mod_test.models import Test, TestPlatform, TestProgress, TestStatus +from mod_api.services.status import batch_get_run_data, is_dummy_row +from mod_api.services.storage import (get_log_file_path, + get_test_results_base_path, + resolve_artifact) +from mod_api.utils import paginated_response, safe_resolve +from mod_test.models import (Test, TestPlatform, TestProgress, TestResultFile, + TestStatus) + +OCTET_STREAM = 'application/octet-stream' @mod_api.route('/system/health', methods=['GET']) @@ -202,3 +211,137 @@ def get_queue(limit=50, offset=0): 'running_count': running_count, } ) + + +def _get_gcs_artifacts(run_id, platform): + binary_name = ( + 'ccextractor' if platform == TestPlatform.linux + else 'ccextractorwinfull.exe' + ) + gcs_artifacts = [ + ('binary', + f'test_artifacts/{run_id}/{binary_name}', binary_name, OCTET_STREAM), + ('coredump', f'test_artifacts/{run_id}/coredump', + f'coredump-{run_id}', OCTET_STREAM), + ( + 'combined_stdout', + f'test_artifacts/{run_id}/combined_stdout.log', + f'combined_stdout-{run_id}.log', + 'text/plain', + ), + ] + artifacts = [] + for artifact_type, gcs_path, filename, content_type in gcs_artifacts: + download_url, storage_status = resolve_artifact(gcs_path) + artifacts.append({ + 'artifact_id': f'{artifact_type}_{run_id}', + 'run_id': run_id, + 'sample_id': None, + 'type': artifact_type, + 'filename': filename, + 'content_type': content_type, + 'size_bytes': None, + 'storage_status': storage_status, + 'download_url': download_url, + }) + return artifacts + + +def _get_output_artifacts(run_id): + result_files = TestResultFile.query.options( + joinedload(TestResultFile.regression_test_output), + joinedload(TestResultFile.regression_test), + ).filter_by(test_id=run_id).all() + for rf in result_files: + if is_dummy_row(rf): + continue + + ext = rf.regression_test_output.correct_extension if rf.regression_test_output else '' + sample_id = (rf.regression_test.sample_id + if rf.regression_test else None) + + expected_name = rf.expected + ext + # NOTE: storage metadata (storage_status, download_url, size_bytes, + # content_type) is resolved by list_artifacts for paged items only. + + yield { + 'artifact_id': f'expected_{run_id}_{rf.regression_test_id}_{rf.regression_test_output_id}', + 'run_id': run_id, + 'sample_id': sample_id, + 'type': 'expected_output', + 'filename': expected_name, + } + + if rf.got is not None: + actual_name = rf.got + ext + yield { + 'artifact_id': f'actual_{run_id}_{rf.regression_test_id}_{rf.regression_test_output_id}', + 'run_id': run_id, + 'sample_id': sample_id, + 'type': 'actual_output', + 'filename': actual_name, + } + + +@mod_api.route('/runs//artifacts', methods=['GET']) +@require_scope(Scope.RESULTS_READ) +@validate_path_id('run_id') +@validate_offset_pagination() +def list_artifacts(run_id, limit=50, offset=0): + """ + List all artifacts for a run. + + Checks both GCS and local storage. Falls back to local when GCS + is unavailable. Supports ?type filter. + """ + test = Test.query.filter(Test.id == run_id).first() + if test is None: + return make_error_response( + 'not_found', + f'Run {run_id} not found.', + http_status=404) + + artifacts = _get_gcs_artifacts(run_id, test.platform) + + # Build log — accessed via /runs/{id}/logs, no direct download link. + log_path = get_log_file_path(run_id) + artifacts.append({ + 'artifact_id': f'buildlog_{run_id}', + 'run_id': run_id, + 'sample_id': None, + 'type': 'build_log', + 'filename': f'{run_id}.txt', + 'content_type': 'text/plain', + 'size_bytes': os.path.getsize(log_path) if log_path else None, + 'storage_status': 'ok' if log_path else 'missing', + 'download_url': None, + }) + + artifacts.extend(list(_get_output_artifacts(run_id))) + + # Apply optional ?type filter. + type_filter = request.args.get('type') + if type_filter: + artifacts = [a for a in artifacts if a['type'] == type_filter] + + total = len(artifacts) + paged = artifacts[offset:offset + limit] + + # Resolve heavy artifact metadata only for the returned page + base_path = get_test_results_base_path() + for a in paged: + if 'storage_status' not in a: + # It's an output artifact + filename = a['filename'] + url, status = resolve_artifact(f'TestResults/{filename}') + local = safe_resolve(base_path, filename) + + a['content_type'] = OCTET_STREAM + a['size_bytes'] = ( + os.path.getsize(local) + if local and os.path.isfile(local) else None + ) + a['storage_status'] = status + a['download_url'] = url + + return paginated_response(paged, total, limit, offset) diff --git a/mod_ci/controllers.py b/mod_ci/controllers.py index 4667b4d4b..1e0498f44 100755 --- a/mod_ci/controllers.py +++ b/mod_ci/controllers.py @@ -2346,6 +2346,11 @@ def progress_reporter(test_id, token): if not upload_type_request(log, test_id, repo_folder, test, request): return "EMPTY" + elif request.form['type'] == 'artifactupload': + log.info(f'[PROGRESS_REPORTER][Test: {test_id}] Artifact upload') + if not upload_artifact_type_request(log, test_id, repo_folder, test, request): + return "EMPTY" + elif request.form['type'] == 'finish': log.info(f'[PROGRESS_REPORTER][Test: {test_id}] Test finished') finish_type_request(log, test_id, test, request) @@ -2675,6 +2680,78 @@ def upload_type_request(log, test_id, repo_folder, test, request) -> bool: return False +# Artifact types the CI VM may upload, mapped to the fixed filenames the REST +# API expects under /test_artifacts// (see the +# system route _get_gcs_artifacts in mod_api). +ARTIFACT_TYPES = frozenset({'binary', 'coredump', 'combined_stdout'}) + + +def _artifact_target_name(artifact_type: str, platform) -> Optional[str]: + """Map an artifact type + platform to the exact filename the API resolves.""" + if artifact_type == 'binary': + return 'ccextractor' if platform == TestPlatform.linux else 'ccextractorwinfull.exe' + if artifact_type == 'coredump': + return 'coredump' + if artifact_type == 'combined_stdout': + return 'combined_stdout.log' + return None + + +def upload_artifact_type_request(log, test_id, repo_folder, test, request) -> bool: + """ + Handle the artifactupload request type for the progress reporter. + + Stores a CI artifact (binary, coredump, or combined stdout log) under + ``/test_artifacts//`` with the fixed name the + REST API expects. The target name is derived server-side from + ``artifact_type`` and the test platform, never from the uploaded filename, + so a crafted filename cannot escape the artifact directory. + + :param log: logger + :type log: Logger + :param test_id: the id of the test the artifact belongs to + :type test_id: int + :param repo_folder: SAMPLE_REPOSITORY path + :type repo_folder: str + :param test: the concerned test + :type test: Test + :param request: request parameters + :type request: Request + :return: True on success, False on validation failure + :rtype: bool + """ + artifact_type = request.form.get('artifact_type', '') + if artifact_type not in ARTIFACT_TYPES: + log.warning(f'[Test: {test_id}] Rejected artifact upload: bad artifact_type {artifact_type!r}') + return False + + if 'file' not in request.files: + log.warning(f'[Test: {test_id}] Artifact upload missing file') + return False + + uploaded_file = request.files['file'] + if secure_filename(uploaded_file.filename or '') == '': + log.warning(f'[Test: {test_id}] Artifact upload has empty filename') + return False + + target_name = _artifact_target_name(artifact_type, test.platform) + if target_name is None: + return False + + artifact_dir = os.path.join(repo_folder, 'test_artifacts', str(test.id)) + temp_dir = os.path.join(repo_folder, 'TempFiles') + os.makedirs(artifact_dir, exist_ok=True) + os.makedirs(temp_dir, exist_ok=True) + + temp_path = os.path.join(temp_dir, f'artifact_{test.id}_{target_name}') + uploaded_file.save(temp_path) + final_path = os.path.join(artifact_dir, target_name) + os.replace(temp_path, final_path) + + log.info(f'[Test: {test_id}] Stored {artifact_type} artifact at {final_path}') + return True + + def finish_type_request(log, test_id, test, request): """ Handle finish request type for progress reporter. diff --git a/tests/api/test_routes_system.py b/tests/api/test_routes_system.py index bd71310de..a0870275d 100644 --- a/tests/api/test_routes_system.py +++ b/tests/api/test_routes_system.py @@ -1,17 +1,22 @@ import json -from unittest.mock import patch +import os +import tempfile +from unittest.mock import MagicMock, patch from flask import g from mod_api.middleware.rate_limit import _rate_limit_store from mod_auth.models import Role, User -from mod_test.models import Fork, Test, TestPlatform, TestType +from mod_regression.models import RegressionTestOutput +from mod_test.models import Fork, Test, TestPlatform, TestResultFile, TestType from tests.api.base import ApiTestCase class TestRoutesSystem(ApiTestCase): def setUp(self): super().setUp() + self.test_dir = tempfile.TemporaryDirectory() + self.dir_path = self.test_dir.name # Create users admin2 = User('admin2', Role.admin, 'admin2@local.com', @@ -34,6 +39,10 @@ def setUp(self): _rate_limit_store.clear() + def tearDown(self): + self.test_dir.cleanup() + super().tearDown() + def generate_system_token(self, email, password, scopes=None): payload = { 'email': email, @@ -84,6 +93,79 @@ def test_system_queue_platform_filter(self): self.assertEqual(res.status_code, 200) self.assertEqual(res.json['meta']['queue_depth'], 0) + @patch('run.storage_client_bucket') + def test_list_artifacts(self, mock_bucket): + # Setup mock behavior for GCS + mock_blob = MagicMock() + mock_blob.exists.return_value = True + mock_blob.generate_signed_url.return_value = 'https://signed.url' + mock_bucket.blob.return_value = mock_blob + + # Create real files + os.makedirs(os.path.join(self.dir_path, 'LogFiles'), exist_ok=True) + log_path = os.path.join( + self.dir_path, 'LogFiles', f'{self.test_id}.txt') + with open(log_path, 'w') as f: + f.write('log content') + + os.makedirs(os.path.join(self.dir_path, 'TestResults'), exist_ok=True) + with open(os.path.join(self.dir_path, 'TestResults', 'got.srt'), 'w') as f: + f.write('actual content') + + # Add test result files + rf = TestResultFile(self.test_id, 1, 1, 'expected', 'got') + rto = RegressionTestOutput(1, 1, 'expected', 'out.txt') + rf.regression_test_output = rto + g.db.add(rf) + g.db.commit() + + # Create local file for actual to pass isfile check (already done above) + + original_sample_repo = self.app.config.get('SAMPLE_REPOSITORY') + self.app.config['SAMPLE_REPOSITORY'] = self.dir_path + try: + token = self.generate_system_token( + 'user2@local.com', 'userpass123', ['results:read']) + res = self.client.get( + f'/api/v1/runs/{self.test_id}/artifacts', headers={'Authorization': f'Bearer {token}'}) + self.assertEqual(res.status_code, 200) + finally: + if original_sample_repo is not None: + self.app.config['SAMPLE_REPOSITORY'] = original_sample_repo + else: + del self.app.config['SAMPLE_REPOSITORY'] + + items = res.json['data'] + # We expect: binary, coredump, combined_stdout, build_log, expected_output, actual_output + self.assertEqual(len(items), 6) + + types = [item['type'] for item in items] + self.assertIn('binary', types) + self.assertIn('build_log', types) + self.assertIn('expected_output', types) + self.assertIn('actual_output', types) + + def test_list_artifacts_not_found(self): + token = self.generate_system_token( + 'user2@local.com', 'userpass123', ['results:read']) + res = self.client.get('/api/v1/runs/9999/artifacts', + headers={'Authorization': f'Bearer {token}'}) + self.assertEqual(res.status_code, 404) + + def test_list_artifacts_missing_storage(self): + # When files do not exist, verify storage_status='missing' and download_url=None + token = self.generate_system_token( + 'user2@local.com', 'userpass123', ['results:read']) + res = self.client.get( + f'/api/v1/runs/{self.test_id}/artifacts', headers={'Authorization': f'Bearer {token}'}) + self.assertEqual(res.status_code, 200) + + # Verify the build log artifact has storage_status 'missing' since we didn't create the log file + build_log = next( + a for a in res.json['data'] if a['type'] == 'build_log') + self.assertEqual(build_log['storage_status'], 'missing') + self.assertIsNone(build_log['download_url']) + @patch('mod_api.routes.system.text') def test_system_health_db_down(self, mock_text): mock_text.side_effect = Exception('DB Down') @@ -93,6 +175,15 @@ def test_system_health_db_down(self, mock_text): db_dep = next(d for d in res.json['dependencies'] if d['name'] == 'database') self.assertEqual(db_dep['status'], 'down') + def test_list_artifacts_type_filter(self): + token = self.generate_system_token( + 'user2@local.com', 'userpass123', ['results:read']) + res = self.client.get( + f'/api/v1/runs/{self.test_id}/artifacts?type=build_log', headers={'Authorization': f'Bearer {token}'}) + self.assertEqual(res.status_code, 200) + self.assertEqual(len(res.json['data']), 1) + self.assertEqual(res.json['data'][0]['type'], 'build_log') + def test_safe_resolve_path_traversal(self): from mod_api.utils import safe_resolve base = '/safe/base/path' diff --git a/tests/test_ci/test_controllers.py b/tests/test_ci/test_controllers.py index 8ff86f7dc..3b55ef89b 100644 --- a/tests/test_ci/test_controllers.py +++ b/tests/test_ci/test_controllers.py @@ -1797,6 +1797,110 @@ def test_progress_reporter_finish_type(self, mock_finish_type, mock_test, mock_r mock_request.assert_not_called() mock_finish_type.assert_called_once_with(mock.ANY, 1, mock.ANY, mock.ANY) + @mock.patch('mod_ci.controllers.request') + @mock.patch('mod_ci.controllers.Test') + @mock.patch('mod_ci.controllers.upload_artifact_type_request') + def test_progress_reporter_artifactupload_type(self, mock_artifact_type, mock_test, mock_request): + """Test progress_reporter dispatches the artifactupload type to the handler.""" + from mod_ci.controllers import progress_reporter + + mock_test_obj = MagicMock() + mock_test_obj.token = "token" + mock_test.query.filter.return_value.first.return_value = mock_test_obj + mock_request.form = {'type': 'artifactupload'} + mock_artifact_type.return_value = True + + self.assertEqual("OK", progress_reporter(1, "token")) + mock_artifact_type.assert_called_once_with(mock.ANY, 1, mock.ANY, mock.ANY, mock.ANY) + + @mock.patch('mod_ci.controllers.request') + @mock.patch('mod_ci.controllers.Test') + @mock.patch('mod_ci.controllers.upload_artifact_type_request') + def test_progress_reporter_artifactupload_type_empty(self, mock_artifact_type, mock_test, mock_request): + """Test progress_reporter returns EMPTY when the artifact upload fails.""" + from mod_ci.controllers import progress_reporter + + mock_test_obj = MagicMock() + mock_test_obj.token = "token" + mock_test.query.filter.return_value.first.return_value = mock_test_obj + mock_request.form = {'type': 'artifactupload'} + mock_artifact_type.return_value = False + + self.assertEqual("EMPTY", progress_reporter(1, "token")) + mock_artifact_type.assert_called_once_with(mock.ANY, 1, mock.ANY, mock.ANY, mock.ANY) + + def test_upload_artifact_type_request_bad_type(self): + """Reject artifact uploads whose artifact_type is not whitelisted.""" + from mod_ci.controllers import upload_artifact_type_request + + mock_log = MagicMock() + mock_request = MagicMock() + mock_request.form = {'artifact_type': 'not_a_real_type'} + + self.assertFalse( + upload_artifact_type_request(mock_log, 1, MagicMock(), MagicMock(), mock_request)) + mock_log.warning.assert_called_once() + + def test_upload_artifact_type_request_no_file(self): + """Reject artifact uploads with no file part.""" + from mod_ci.controllers import upload_artifact_type_request + + mock_log = MagicMock() + mock_request = MagicMock() + mock_request.form = {'artifact_type': 'binary'} + mock_request.files = {} + + self.assertFalse( + upload_artifact_type_request(mock_log, 1, MagicMock(), MagicMock(), mock_request)) + + @mock.patch('mod_ci.controllers.secure_filename') + def test_upload_artifact_type_request_empty_filename(self, mock_filename): + """Reject artifact uploads with an empty filename.""" + from mod_ci.controllers import upload_artifact_type_request + + mock_log = MagicMock() + mock_request = MagicMock() + mock_request.form = {'artifact_type': 'combined_stdout'} + mock_request.files = {'file': MagicMock()} + mock_filename.return_value = '' + + self.assertFalse( + upload_artifact_type_request(mock_log, 1, MagicMock(), MagicMock(), mock_request)) + + @mock.patch('mod_ci.controllers.os') + @mock.patch('mod_ci.controllers.secure_filename') + def test_upload_artifact_type_request(self, mock_filename, mock_os): + """Store a valid artifact under test_artifacts// with the API's expected name.""" + from mod_ci.controllers import upload_artifact_type_request + from mod_test.models import TestPlatform + + mock_log = MagicMock() + mock_request = MagicMock() + mock_uploadfile = MagicMock() + mock_request.form = {'artifact_type': 'binary'} + mock_request.files = {'file': mock_uploadfile} + mock_filename.return_value = 'ccextractor' + mock_test = MagicMock() + mock_test.id = 7 + mock_test.platform = TestPlatform.linux + + self.assertTrue( + upload_artifact_type_request(mock_log, 7, '/repo', mock_test, mock_request)) + mock_uploadfile.save.assert_called_once() + mock_os.replace.assert_called_once() + self.assertTrue(mock_os.makedirs.called) + + def test_artifact_target_name_mapping(self): + """Artifact filenames are derived server-side per type and platform.""" + from mod_ci.controllers import _artifact_target_name + from mod_test.models import TestPlatform + + self.assertEqual('ccextractor', _artifact_target_name('binary', TestPlatform.linux)) + self.assertEqual('ccextractorwinfull.exe', _artifact_target_name('binary', TestPlatform.windows)) + self.assertEqual('coredump', _artifact_target_name('coredump', TestPlatform.linux)) + self.assertEqual('combined_stdout.log', _artifact_target_name('combined_stdout', TestPlatform.windows)) + self.assertIsNone(_artifact_target_name('bogus', TestPlatform.linux)) + @mock.patch('mod_ci.controllers.RegressionTestOutput') def test_equality_type_request_rto_none(self, mock_rto): """Test function equality_type_request when rto is None."""