From ac53d503c2f02063a052fe53e592be86c662bd3d Mon Sep 17 00:00:00 2001 From: Pulkit Chauhan Date: Thu, 30 Jul 2026 22:59:44 +0530 Subject: [PATCH] PR 8: Administration and Web Console Endpoints --- mod_api/__init__.py | 2 + mod_api/models/api_token.py | 4 + mod_api/routes/auth.py | 83 +- mod_api/routes/regression_tests.py | 379 +++++++ mod_api/routes/samples.py | 72 +- mod_api/routes/system.py | 228 +++- mod_api/schemas/auth.py | 20 +- mod_api/schemas/regression_tests.py | 94 ++ mod_api/schemas/system.py | 50 + openapi-ci-api.yaml | 1178 ++++++++++++++++++++- scripts/verify_schemathesis.py | 16 +- tests/api/test_routes_auth.py | 106 ++ tests/api/test_routes_regression_tests.py | 357 +++++++ tests/api/test_routes_samples.py | 43 + tests/api/test_routes_system.py | 186 ++++ 15 files changed, 2790 insertions(+), 28 deletions(-) create mode 100644 mod_api/routes/regression_tests.py create mode 100644 mod_api/schemas/regression_tests.py create mode 100644 mod_api/schemas/system.py create mode 100644 tests/api/test_routes_regression_tests.py diff --git a/mod_api/__init__.py b/mod_api/__init__.py index d5b080bf..cf327dc7 100644 --- a/mod_api/__init__.py +++ b/mod_api/__init__.py @@ -37,6 +37,8 @@ from mod_api.routes import auth as auth_routes # noqa: E402, F401 from mod_api.routes import \ errors_logs as errors_logs_routes # noqa: E402, F401 +from mod_api.routes import \ + regression_tests as regression_tests_routes # noqa: E402, F401 from mod_api.routes import results as results_routes # noqa: E402, F401 from mod_api.routes import runs as runs_routes # noqa: E402, F401 from mod_api.routes import samples as samples_routes # noqa: E402, F401 diff --git a/mod_api/models/api_token.py b/mod_api/models/api_token.py index a4ec2647..996c21c8 100644 --- a/mod_api/models/api_token.py +++ b/mod_api/models/api_token.py @@ -36,6 +36,9 @@ class Scope: RESULTS_READ = 'results:read' BASELINES_WRITE = 'baselines:write' SYSTEM_READ = 'system:read' + # Kept apart from system:read so a monitoring token can watch the + # platform without being able to reconfigure it. + SYSTEM_WRITE = 'system:write' TOKENS_MANAGE = 'tokens:manage' @@ -45,6 +48,7 @@ class Scope: Scope.RESULTS_READ, Scope.BASELINES_WRITE, Scope.SYSTEM_READ, + Scope.SYSTEM_WRITE, Scope.TOKENS_MANAGE, ]) diff --git a/mod_api/routes/auth.py b/mod_api/routes/auth.py index d39d6e80..d397ec2b 100644 --- a/mod_api/routes/auth.py +++ b/mod_api/routes/auth.py @@ -1,10 +1,13 @@ """ -Token lifecycle: create, list, and revoke API tokens. +Token lifecycle, caller identity, and admin user management. POST /auth/tokens Authenticate with email/password, get a token GET /auth/tokens List tokens (admin-only; ?all=true for all users) DELETE /auth/tokens/current Revoke the token you're currently using DELETE /auth/tokens/{id} Revoke a specific token by ID +GET /auth/me Identity, role and scopes behind the token +GET /users List platform users (admin) +PATCH /users/{id} Change a user's role (admin) """ from flask import g, request @@ -15,10 +18,11 @@ from mod_api.middleware.auth import require_roles, require_scope from mod_api.middleware.error_handler import make_error_response from mod_api.middleware.validation import (validate_body, - validate_offset_pagination) + validate_offset_pagination, + validate_path_id) from mod_api.models.api_token import DEFAULT_SCOPES, ApiToken, Scope from mod_api.schemas.auth import (ApiTokenItemSchema, AuthTokenSchema, - TokenCreateRequestSchema) + RoleUpdateSchema, TokenCreateRequestSchema) from mod_api.utils import paginated_response, single_response from mod_auth.models import Role, User @@ -74,6 +78,7 @@ def create_token(validated_data=None): if user.is_admin: allowed_scopes.add(Scope.TOKENS_MANAGE) allowed_scopes.add(Scope.BASELINES_WRITE) + allowed_scopes.add(Scope.SYSTEM_WRITE) invalid_scopes = set(scopes) - allowed_scopes if invalid_scopes: @@ -211,3 +216,75 @@ def revoke_specific_token(token_id): g.db.commit() return '', 204 + + +@mod_api.route('/auth/me', methods=['GET']) +def get_current_user(): + """ + Return the identity, role and scopes behind the calling token. + + Needs no extra scope: it reports on the caller itself and discloses + nothing another endpoint would withhold. + """ + return single_response({ + 'user_id': g.api_user.id, + 'name': g.api_user.name, + 'email': g.api_user.email, + 'role': g.api_user.role.value, + 'scopes': g.api_token.scopes if g.api_token else [], + }) + + +def _serialize_user(user): + """Public shape of a user; omits password hash and GitHub token.""" + return { + 'user_id': user.id, + 'name': user.name, + 'email': user.email, + 'role': user.role.value, + 'github_linked': bool(user.github_login), + 'github_login': user.github_login, + } + + +@mod_api.route('/users', methods=['GET']) +@require_roles([Role.admin]) +@require_scope(Scope.TOKENS_MANAGE) +@validate_offset_pagination() +def list_users(limit=50, offset=0): + """List platform users, oldest first.""" + query = User.query.order_by(User.id.asc()) + total = query.count() + users = query.offset(offset).limit(limit).all() + return paginated_response( + [_serialize_user(user) for user in users], total, limit, offset) + + +@mod_api.route('/users/', methods=['PATCH']) +@require_roles([Role.admin]) +@require_scope(Scope.TOKENS_MANAGE) +@validate_path_id('user_id') +@validate_body(RoleUpdateSchema) +def update_user_role(user_id, validated_data=None): + """ + Change a user's role. + + Admins cannot change their own role: demoting the last admin here + would leave nobody able to undo it. + """ + user = User.query.filter(User.id == user_id).first() + if user is None: + return make_error_response( + 'not_found', f'User {user_id} not found.', http_status=404) + + if user.id == g.api_user.id: + return make_error_response( + 'forbidden', 'You cannot change your own role.', http_status=403) + + previous_role = user.role.value + user.role = Role.from_string(validated_data['role']) + g.db.commit() + + g.log.info(f'user {user.id} role {previous_role} -> {user.role.value} ' + f'by admin {g.api_user.id}') + return single_response(_serialize_user(user)) diff --git a/mod_api/routes/regression_tests.py b/mod_api/routes/regression_tests.py new file mode 100644 index 00000000..a113d73d --- /dev/null +++ b/mod_api/routes/regression_tests.py @@ -0,0 +1,379 @@ +""" +Regression test detail/write endpoints and the categories that group them. + +GET /regression-tests/{id} Full detail, including baselines and variants +POST /regression-tests Create a test (inactive unless asked otherwise) +PATCH /regression-tests/{id} Partially update a test +DELETE /regression-tests/{id} Delete a test that has never run +GET /categories List categories with their test counts +POST /categories Create a category +PATCH /categories/{id} Rename or re-describe a category +DELETE /categories/{id} Delete a category no test references + +Editing the suite previously meant hand-written SQL against the production +database. The regression test list view stays in routes/samples.py. +""" + +from flask import g +from sqlalchemy import func +from sqlalchemy.exc import IntegrityError + +from mod_api import mod_api +from mod_api.middleware.auth import require_roles, require_scope +from mod_api.middleware.error_handler import make_error_response +from mod_api.middleware.validation import (validate_body, + validate_offset_pagination, + validate_path_id) +from mod_api.models.api_token import Scope +from mod_api.routes.samples import serialize_rt +from mod_api.schemas.regression_tests import (CategoryCreateSchema, + CategoryUpdateSchema, + RegressionTestCreateSchema, + RegressionTestUpdateSchema) +from mod_api.utils import paginated_response, single_response +from mod_auth.models import Role +from mod_regression.models import (Category, InputType, OutputType, + RegressionTest, RegressionTestOutput, + RegressionTestOutputFiles, + regressionTestLinkTable) +from mod_sample.models import Sample +from mod_test.models import TestResult + + +def _resolve_categories(names): + """ + Look up categories by name. + + Returns the matching rows plus the names that matched nothing, so the + caller can reject the whole request rather than silently dropping a + category the client believed it had set. + """ + rows = Category.query.filter(Category.name.in_(names)).all() + found = {category.name for category in rows} + return rows, [name for name in names if name not in found] + + +def _unknown_categories_response(unknown): + """Build the 400 returned when a request names categories that don't exist.""" + return make_error_response( + 'validation_error', + f"Unknown categories: {', '.join(unknown)}", + details={'fields': {'categories': unknown}}, + http_status=400, + ) + + +@mod_api.route('/regression-tests/', methods=['GET']) +@require_scope(Scope.RUNS_READ) +@validate_path_id('regression_test_id') +def get_regression_test(regression_test_id): + """ + Return one regression test with its baselines. + + The list endpoint omits outputs because they multiply the payload for + every row; a detail view needs them to show what "expected" means, + including the alternative hashes accepted as variants. + """ + test = RegressionTest.query.filter( + RegressionTest.id == regression_test_id).first() + if test is None: + return make_error_response( + 'not_found', f'Regression test {regression_test_id} not found.', + http_status=404) + + data = serialize_rt(test) + data['outputs'] = [ + { + 'id': output.id, + 'correct': output.correct, + 'correct_extension': output.correct_extension, + 'expected_filename': output.expected_filename, + 'ignore': output.ignore, + 'variants': [f.file_hashes for f in output.multiple_files], + } + for output in test.output_files + ] + return single_response(data) + + +@mod_api.route('/regression-tests', methods=['POST']) +@require_roles([Role.contributor, Role.admin]) +@require_scope(Scope.RUNS_WRITE) +@validate_body(RegressionTestCreateSchema) +def create_regression_test(validated_data=None): + """ + Create a regression test. + + Created inactive unless the body says otherwise, so it cannot join a CI + suite before someone has seen the output it actually produces. + """ + data = validated_data + + sample = Sample.query.filter(Sample.id == data['sample_id']).first() + if sample is None: + return make_error_response( + 'not_found', f"Sample {data['sample_id']} not found.", + http_status=404) + + categories, unknown = _resolve_categories(data['categories']) + if unknown: + return _unknown_categories_response(unknown) + + test = RegressionTest( + sample_id=sample.id, + command=data['command'], + input_type=InputType.from_string(data['input_type']), + output_type=OutputType.from_string(data['output_type']), + # Categories live in a link table; the constructor argument is a + # leftover from the single-category era and maps to no column. + category_id=None, + expected_rc=data['expected_rc'], + active=data['active'], + description=data['description'], + ) + test.categories = categories + g.db.add(test) + g.db.commit() + + g.log.info(f'regression test {test.id} created via API by {g.api_user.id}') + return single_response(serialize_rt(test), http_status=201) + + +@mod_api.route('/regression-tests/', methods=['PATCH']) +@require_roles([Role.contributor, Role.admin]) +@require_scope(Scope.RUNS_WRITE) +@validate_path_id('regression_test_id') +@validate_body(RegressionTestUpdateSchema) +def update_regression_test(regression_test_id, validated_data=None): + """ + Update part of a regression test. + + Only the fields present in the body are touched, so two clients editing + different fields cannot clobber each other's work. + """ + test = RegressionTest.query.filter( + RegressionTest.id == regression_test_id).first() + if test is None: + return make_error_response( + 'not_found', f'Regression test {regression_test_id} not found.', + http_status=404) + + data = validated_data + if not data: + return make_error_response( + 'validation_error', 'No fields to update.', http_status=400) + + if 'categories' in data: + categories, unknown = _resolve_categories(data['categories']) + if unknown: + return _unknown_categories_response(unknown) + test.categories = categories + + for field in ('command', 'description', 'expected_rc', 'active'): + if field in data: + setattr(test, field, data[field]) + for field, enum in (('input_type', InputType), ('output_type', OutputType)): + if field in data: + setattr(test, field, enum.from_string(data[field])) + + g.db.commit() + + g.log.info(f'regression test {test.id} updated via API by ' + f'{g.api_user.id}: {sorted(data.keys())}') + return single_response(serialize_rt(test)) + + +@mod_api.route('/regression-tests/', methods=['DELETE']) +@require_roles([Role.contributor, Role.admin]) +@require_scope(Scope.RUNS_WRITE) +@validate_path_id('regression_test_id') +def delete_regression_test(regression_test_id): + """ + Delete a regression test that has never run. + + Once results reference it the test carries history, and removing it + would erase evidence of past regressions, so it is refused with 409 and + the result count. Retiring such a test is a PATCH with active=false. + + Baselines, variants and category links are cleared first: those foreign + keys are RESTRICT and would otherwise block the delete. + """ + test = RegressionTest.query.filter( + RegressionTest.id == regression_test_id).first() + if test is None: + return make_error_response( + 'not_found', f'Regression test {regression_test_id} not found.', + http_status=404) + + result_count = TestResult.query.filter_by( + regression_test_id=test.id).count() + if result_count: + return make_error_response( + 'conflict', + f'Regression test {regression_test_id} has {result_count} ' + f'historical result(s). PATCH active=false to retire it instead.', + details={'result_count': result_count}, + http_status=409, + ) + + deleted_id = test.id + # Queried rather than read off test.output_files: deleting rows underneath + # a live relationship collection mutates it mid-iteration. The outputs go + # through the session so it knows they are gone before the test itself is + # deleted, otherwise SQLAlchemy tries to orphan them and hits stale rows. + outputs = RegressionTestOutput.query.filter_by(regression_id=test.id).all() + for output in outputs: + RegressionTestOutputFiles.query.filter_by( + regression_test_output_id=output.id).delete( + synchronize_session=False) + g.db.delete(output) + + test.categories = [] + g.db.delete(test) + g.db.commit() + + g.log.info(f'regression test {deleted_id} deleted via API by ' + f'{g.api_user.id}') + return single_response({'id': deleted_id, 'deleted': True}) + + +def _test_counts(category_ids): + """Count the regression tests linked to each of these categories.""" + if not category_ids: + return {} + link = regressionTestLinkTable + return dict(g.db.query( + link.c.category_id, func.count(link.c.regression_id) + ).filter( + link.c.category_id.in_(category_ids) + ).group_by(link.c.category_id).all()) + + +def _serialize_category(category, test_count): + """Public shape of a category, including how many tests reference it.""" + return { + 'id': category.id, + 'name': category.name, + 'description': category.description, + 'test_count': test_count, + } + + +@mod_api.route('/categories', methods=['GET']) +@require_scope(Scope.RUNS_READ) +@validate_offset_pagination() +def list_categories(limit=50, offset=0): + """List categories alphabetically.""" + query = Category.query.order_by(Category.name.asc()) + total = query.count() + rows = query.offset(offset).limit(limit).all() + + # Counted in one grouped query rather than per row: reading + # category.regression_tests would load every linked test just to size it. + counts = _test_counts([row.id for row in rows]) + return paginated_response( + [_serialize_category(row, counts.get(row.id, 0)) for row in rows], + total, limit, offset) + + +@mod_api.route('/categories', methods=['POST']) +@require_roles([Role.contributor, Role.admin]) +@require_scope(Scope.RUNS_WRITE) +@validate_body(CategoryCreateSchema) +def create_category(validated_data=None): + """Create a category. Names are unique, so a duplicate is a 409.""" + name = validated_data['name'] + if Category.query.filter(Category.name == name).first() is not None: + return make_error_response( + 'conflict', f"Category '{name}' already exists.", http_status=409) + + category = Category(name, validated_data['description']) + g.db.add(category) + try: + g.db.commit() + except IntegrityError: + # name is unique, so a request that raced the check above ends here. + g.db.rollback() + return make_error_response( + 'conflict', f"Category '{name}' already exists.", http_status=409) + + g.log.info(f'category {category.id} created via API by {g.api_user.id}') + return single_response(_serialize_category(category, 0), http_status=201) + + +@mod_api.route('/categories/', methods=['PATCH']) +@require_roles([Role.contributor, Role.admin]) +@require_scope(Scope.RUNS_WRITE) +@validate_path_id('category_id') +@validate_body(CategoryUpdateSchema) +def update_category(category_id, validated_data=None): + """Rename or re-describe a category.""" + category = Category.query.filter(Category.id == category_id).first() + if category is None: + return make_error_response( + 'not_found', f'Category {category_id} not found.', http_status=404) + + data = validated_data + if not data: + return make_error_response( + 'validation_error', 'No fields to update.', http_status=400) + + # Comparing against the current name first keeps re-sending your own + # name from colliding with yourself. + if 'name' in data and data['name'] != category.name: + if Category.query.filter(Category.name == data['name']).first(): + return make_error_response( + 'conflict', f"Category '{data['name']}' already exists.", + http_status=409) + category.name = data['name'] + + if 'description' in data: + category.description = data['description'] + + try: + g.db.commit() + except IntegrityError: + g.db.rollback() + return make_error_response( + 'conflict', f"Category '{data.get('name')}' already exists.", + http_status=409) + + g.log.info(f'category {category.id} updated via API by {g.api_user.id}') + counts = _test_counts([category.id]) + return single_response( + _serialize_category(category, counts.get(category.id, 0))) + + +@mod_api.route('/categories/', methods=['DELETE']) +@require_roles([Role.contributor, Role.admin]) +@require_scope(Scope.RUNS_WRITE) +@validate_path_id('category_id') +def delete_category(category_id): + """ + Delete a category no regression test references. + + Dropping one still in use would change which tests a suite selection + picks up, so it is refused with 409 and the count. Detaching the tests + first is a PATCH on each test's categories. + """ + category = Category.query.filter(Category.id == category_id).first() + if category is None: + return make_error_response( + 'not_found', f'Category {category_id} not found.', http_status=404) + + in_use = _test_counts([category.id]).get(category.id, 0) + if in_use: + return make_error_response( + 'conflict', + f'Category {category_id} is used by {in_use} regression test(s). ' + f'Detach them before deleting it.', + details={'regression_test_count': in_use}, + http_status=409, + ) + + deleted_id = category.id + g.db.delete(category) + g.db.commit() + + g.log.info(f'category {deleted_id} deleted via API by {g.api_user.id}') + return single_response({'id': deleted_id, 'deleted': True}) diff --git a/mod_api/routes/samples.py b/mod_api/routes/samples.py index 9ebe7fb0..9a6f80b6 100644 --- a/mod_api/routes/samples.py +++ b/mod_api/routes/samples.py @@ -5,6 +5,7 @@ GET /runs/{id}/samples/{sid} Single result in a run GET /samples Media sample catalog GET /samples/{id} Single media sample +GET /samples/{id}/details Upload metadata, extra files, media info GET /samples/{id}/history Cross-run history for a sample GET /regression-tests Regression test definitions """ @@ -29,9 +30,12 @@ from mod_api.utils import paginated_response, single_response from mod_regression.models import (Category, RegressionTest, RegressionTestOutput) -from mod_sample.models import Sample, Tag +from mod_sample.media_info_parser import (InvalidMediaInfoError, + MediaInfoFetcher) +from mod_sample.models import ExtraFile, Sample, Tag from mod_test.models import (Test, TestPlatform, TestProgress, TestResult, TestResultFile) +from mod_upload.models import Upload # Valid per-sample status values accepted by the ?status filter. Limited to the # statuses derive_sample_status can actually emit, so filtering can't silently @@ -342,6 +346,67 @@ def list_samples(limit=50, offset=0): return paginated_response(serialized, total, limit, offset) +@mod_api.route('/samples//details', methods=['GET']) +@require_scope(Scope.RUNS_READ) +@validate_path_id('sample_id') +def get_sample_details(sample_id): + """ + Everything known about one sample, for a detail view. + + Extends the /samples/{id} summary with the upload record, any extra + files, and the parsed MediaInfo tree. Media info is best-effort: missing + or unparseable XML reports ``null`` rather than failing the response. + Unlike the classic page this never regenerates the XML, because a GET + should not write to the sample repository. + """ + sample = Sample.query.options(joinedload(Sample.tags)).filter( + Sample.id == sample_id).first() + if sample is None: + return make_error_response( + 'not_found', f'Sample {sample_id} not found.', http_status=404) + + upload = Upload.query.filter(Upload.sample_id == sample.id).first() + upload_info = None + if upload is not None: + version = upload.version + upload_info = { + 'platform': upload.platform.value if upload.platform else None, + 'parameters': upload.parameters or '', + 'notes': upload.notes or '', + 'version': version.version if version else None, + 'version_released': ( + version.released.isoformat() + if version and version.released else None), + } + + extra_files = [ + { + 'id': extra.id, + 'original_name': extra.original_name, + 'extension': extra.extension, + } + for extra in ExtraFile.query.filter( + ExtraFile.sample_id == sample.id).all() + ] + + try: + media_info = MediaInfoFetcher(sample).get_media_info() + except InvalidMediaInfoError: + media_info = None + + return single_response({ + 'sample_id': sample.id, + 'sha': sample.sha, + 'extension': sample.extension, + 'original_name': sample.original_name, + 'filename': sample.filename, + 'tags': [tag.name for tag in sample.tags], + 'upload': upload_info, + 'extra_files': extra_files, + 'media_info': media_info, + }) + + @mod_api.route('/samples/', methods=['GET']) @require_scope(Scope.RUNS_READ) @validate_path_id('sample_id') @@ -557,7 +622,8 @@ def get_sample_history( ) -def _serialize_rt(rt): +def serialize_rt(rt): + """Public shape of a regression test definition, without its outputs.""" return { 'regression_test_id': rt.id, 'sample_id': rt.sample_id, @@ -643,5 +709,5 @@ def list_regression_tests(limit=50, offset=0): # Paginate at DB level total = query.count() tests = query.offset(offset).limit(limit).all() - serialized = [_serialize_rt(rt) for rt in tests] + serialized = [serialize_rt(rt) for rt in tests] return paginated_response(serialized, total, limit, offset) diff --git a/mod_api/routes/system.py b/mod_api/routes/system.py index 363ffa11..d5b47ec0 100644 --- a/mod_api/routes/system.py +++ b/mod_api/routes/system.py @@ -1,9 +1,22 @@ """ -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 +System, health, queue, artifact, and platform configuration routes. + +GET /system/health Health check (unauthenticated) +GET /system/queue Queue status — active + queued runs +GET /runs/{id}/artifacts Run artifacts from GCS + local +GET /system/maintenance Maintenance state per platform +PATCH /system/maintenance/{platform} Pause or resume a platform +GET /system/blocked-users CI users blocked from triggering +POST /system/blocked-users Block a GitHub account +DELETE /system/blocked-users/{user_id} Unblock a GitHub account +GET /system/forbidden-extensions Extensions rejected on upload +POST /system/forbidden-extensions Forbid an extension +DELETE /system/forbidden-extensions/{ext} Allow an extension again + +Every configuration route is admin-only, matching the classic maintenance and +blocked-user pages: the blocklist names accounts, and the rest decides whether +CI accepts work at all. Reads additionally need system:read and writes +system:write, so a token can be narrowed further than the role allows. """ import os @@ -11,20 +24,28 @@ from flask import g, jsonify, request from sqlalchemy import text +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import joinedload from mod_api import mod_api -from mod_api.middleware.auth import require_scope +from mod_api.middleware.auth import require_roles, 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_body, + 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.schemas.system import (BlockedUserCreateSchema, + ForbiddenExtensionCreateSchema, + MaintenanceUpdateSchema) 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_api.utils import paginated_response, safe_resolve, single_response +from mod_auth.models import Role +from mod_ci.models import BlockedUsers, MaintenanceMode +from mod_sample.models import ForbiddenExtension from mod_test.models import (Test, TestPlatform, TestProgress, TestResultFile, TestStatus) @@ -345,3 +366,194 @@ def list_artifacts(run_id, limit=50, offset=0): a['download_url'] = url return paginated_response(paged, total, limit, offset) + + +def _maintenance_entry(platform, row): + """Maintenance shape for one platform; no row means never paused.""" + return { + 'platform': platform.value, + 'disabled': bool(row.disabled) if row is not None else False, + } + + +@mod_api.route('/system/maintenance', methods=['GET']) +@require_roles([Role.admin]) +@require_scope(Scope.SYSTEM_READ) +def get_maintenance(): + """ + Report maintenance state, one entry per platform. + + Keyed on ``platforms`` rather than the ``data`` collection envelope: the + list is derived from the platform enum, not a growable table, so there + is nothing to paginate. + """ + # No unique key on platform, so order it: a duplicated row must not make + # the reported state depend on which one the database returns first. + rows = {} + for row in MaintenanceMode.query.order_by(MaintenanceMode.id.asc()).all(): + rows.setdefault(row.platform, row) + return single_response({'platforms': [ + _maintenance_entry(platform, rows.get(platform)) + for platform in TestPlatform + ]}) + + +@mod_api.route('/system/maintenance/', methods=['PATCH']) +@require_roles([Role.admin]) +@require_scope(Scope.SYSTEM_WRITE) +@validate_body(MaintenanceUpdateSchema) +def update_maintenance(platform, validated_data=None): + """ + Pause or resume CI for one platform. + + While a platform is disabled new runs still queue; they are simply not + handed to a VM until it is resumed. + """ + if platform not in TestPlatform.values(): + return make_error_response( + 'validation_error', + f'Invalid platform. Must be one of: ' + f'{", ".join(sorted(TestPlatform.values()))}.', + http_status=400, + ) + + target = TestPlatform.from_string(platform) + disabled = validated_data['disabled'] + row = MaintenanceMode.query.filter( + MaintenanceMode.platform == target).order_by( + MaintenanceMode.id.asc()).first() + if row is None: + row = MaintenanceMode(target, disabled) + g.db.add(row) + else: + row.disabled = disabled + g.db.commit() + + g.log.info(f'maintenance for {target.value} set to disabled={disabled} ' + f'via API by user {g.api_user.id}') + return single_response(_maintenance_entry(target, row)) + + +@mod_api.route('/system/blocked-users', methods=['GET']) +@require_roles([Role.admin]) +@require_scope(Scope.SYSTEM_READ) +@validate_offset_pagination() +def list_blocked_users(limit=50, offset=0): + """List the GitHub accounts blocked from triggering CI runs.""" + query = BlockedUsers.query.order_by(BlockedUsers.user_id.asc()) + total = query.count() + rows = query.offset(offset).limit(limit).all() + return paginated_response( + [{'user_id': row.user_id, 'comment': row.comment or ''} + for row in rows], + total, limit, offset) + + +@mod_api.route('/system/blocked-users', methods=['POST']) +@require_roles([Role.admin]) +@require_scope(Scope.SYSTEM_WRITE) +@validate_body(BlockedUserCreateSchema) +def create_blocked_user(validated_data=None): + """Block a GitHub account from triggering CI runs.""" + user_id = validated_data['user_id'] + if BlockedUsers.query.filter( + BlockedUsers.user_id == user_id).first() is not None: + return make_error_response( + 'conflict', f'GitHub user {user_id} is already blocked.', + http_status=409) + + row = BlockedUsers(user_id, validated_data['comment']) + g.db.add(row) + try: + g.db.commit() + except IntegrityError: + # user_id is the primary key, so a request that raced the check + # above lands here instead of duplicating the row. + g.db.rollback() + return make_error_response( + 'conflict', f'GitHub user {user_id} is already blocked.', + http_status=409) + + g.log.info(f'github user {user_id} blocked via API by user {g.api_user.id}') + return single_response( + {'user_id': row.user_id, 'comment': row.comment or ''}, + http_status=201) + + +@mod_api.route('/system/blocked-users/', methods=['DELETE']) +@require_roles([Role.admin]) +@require_scope(Scope.SYSTEM_WRITE) +def delete_blocked_user(user_id): + """Unblock a GitHub account.""" + row = BlockedUsers.query.filter(BlockedUsers.user_id == user_id).first() + if row is None: + return make_error_response( + 'not_found', f'GitHub user {user_id} is not blocked.', + http_status=404) + + g.db.delete(row) + g.db.commit() + + g.log.info(f'github user {user_id} unblocked via API by {g.api_user.id}') + return single_response({'user_id': user_id, 'deleted': True}) + + +@mod_api.route('/system/forbidden-extensions', methods=['GET']) +@require_roles([Role.admin]) +@require_scope(Scope.SYSTEM_READ) +@validate_offset_pagination() +def list_forbidden_extensions(limit=50, offset=0): + """List the file extensions rejected on upload.""" + query = ForbiddenExtension.query.order_by( + ForbiddenExtension.extension.asc()) + total = query.count() + rows = query.offset(offset).limit(limit).all() + return paginated_response( + [row.extension for row in rows], total, limit, offset) + + +@mod_api.route('/system/forbidden-extensions', methods=['POST']) +@require_roles([Role.admin]) +@require_scope(Scope.SYSTEM_WRITE) +@validate_body(ForbiddenExtensionCreateSchema) +def create_forbidden_extension(validated_data=None): + """Forbid an extension. Stored lower-cased and without a leading dot.""" + extension = validated_data['extension'].lower() + if ForbiddenExtension.query.filter( + ForbiddenExtension.extension == extension).first() is not None: + return make_error_response( + 'conflict', f"Extension '{extension}' is already forbidden.", + http_status=409) + + g.db.add(ForbiddenExtension(extension)) + try: + g.db.commit() + except IntegrityError: + # extension is the primary key; same race as blocking a user. + g.db.rollback() + return make_error_response( + 'conflict', f"Extension '{extension}' is already forbidden.", + http_status=409) + + g.log.info(f'extension {extension} forbidden via API by {g.api_user.id}') + return single_response({'extension': extension}, http_status=201) + + +@mod_api.route('/system/forbidden-extensions/', methods=['DELETE']) +@require_roles([Role.admin]) +@require_scope(Scope.SYSTEM_WRITE) +def delete_forbidden_extension(extension): + """Allow an extension to be uploaded again.""" + normalized = extension.lstrip('.').lower() + row = ForbiddenExtension.query.filter( + ForbiddenExtension.extension == normalized).first() + if row is None: + return make_error_response( + 'not_found', f"Extension '{normalized}' is not forbidden.", + http_status=404) + + g.db.delete(row) + g.db.commit() + + g.log.info(f'extension {normalized} allowed via API by {g.api_user.id}') + return single_response({'extension': normalized, 'deleted': True}) diff --git a/mod_api/schemas/auth.py b/mod_api/schemas/auth.py index bbfc1554..b56ece78 100644 --- a/mod_api/schemas/auth.py +++ b/mod_api/schemas/auth.py @@ -1,9 +1,10 @@ -"""Request/response schemas for the token endpoints.""" +"""Request/response schemas for the token and user endpoints.""" from marshmallow import RAISE, Schema, fields, validate from mod_api.models.api_token import VALID_SCOPES from mod_api.schemas.common import DATETIME_FORMAT +from mod_auth.models import Role class TokenCreateRequestSchema(Schema): @@ -31,7 +32,9 @@ class TokenCreateRequestSchema(Schema): scopes = fields.List( fields.String(validate=validate.OneOf(VALID_SCOPES)), load_default=None, - validate=validate.Length(max=6), + # Bounded by the scope list itself, so adding a scope cannot silently + # make "ask for everything I'm allowed" fail validation. + validate=validate.Length(max=len(VALID_SCOPES)), ) class Meta: @@ -65,3 +68,16 @@ class ApiTokenItemSchema(Schema): def get_scopes(self, obj): """Deserialize scopes from the model's JSON column.""" return obj.scopes + + +class RoleUpdateSchema(Schema): + """Validates PATCH /users/{id} bodies.""" + + # Taken from the model so the accepted values follow the Role enum. + role = fields.String( + required=True, validate=validate.OneOf(sorted(Role.values()))) + + class Meta: + """Reject unknown fields.""" + + unknown = RAISE diff --git a/mod_api/schemas/regression_tests.py b/mod_api/schemas/regression_tests.py new file mode 100644 index 00000000..49734cab --- /dev/null +++ b/mod_api/schemas/regression_tests.py @@ -0,0 +1,94 @@ +"""Request schemas for the regression test and category write endpoints.""" + +from marshmallow import RAISE, Schema, fields, validate + +from mod_regression.models import InputType, OutputType + +# Taken from the models rather than restated, so a type the database accepts +# can never be rejected here (or the reverse) after someone edits the enum. +_INPUT_TYPES = sorted(InputType.values()) +_OUTPUT_TYPES = sorted(OutputType.values()) + +# Matches the maxLength the OpenAPI contract already publishes for a +# regression test's command, so a body accepted here cannot produce a +# response that violates the documented schema. +_COMMAND_MAX = 500 + +# Category column widths, shared with the category name list below. +_NAME_MAX = 64 +_DESCRIPTION_MAX = 1024 + + +class RegressionTestCreateSchema(Schema): + """Validates POST /regression-tests bodies.""" + + sample_id = fields.Integer(required=True, validate=validate.Range(min=1)) + command = fields.String( + required=True, validate=validate.Length(min=1, max=_COMMAND_MAX)) + input_type = fields.String( + load_default='file', validate=validate.OneOf(_INPUT_TYPES)) + output_type = fields.String( + load_default='file', validate=validate.OneOf(_OUTPUT_TYPES)) + expected_rc = fields.Integer( + load_default=0, validate=validate.Range(min=0, max=255)) + description = fields.String( + load_default='', validate=validate.Length(max=_DESCRIPTION_MAX)) + categories = fields.List( + fields.String(validate=validate.Length(min=1, max=_NAME_MAX)), + required=True, + validate=validate.Length(min=1), + ) + # A new test starts inactive: it should only join the CI suite once a + # maintainer has seen what it actually produces on a verification run. + active = fields.Boolean(load_default=False) + + class Meta: + """Reject unknown fields.""" + + unknown = RAISE + + +class RegressionTestUpdateSchema(Schema): + """Validates PATCH /regression-tests/{id} bodies; every field optional.""" + + command = fields.String(validate=validate.Length(min=1, max=_COMMAND_MAX)) + input_type = fields.String(validate=validate.OneOf(_INPUT_TYPES)) + output_type = fields.String(validate=validate.OneOf(_OUTPUT_TYPES)) + expected_rc = fields.Integer(validate=validate.Range(min=0, max=255)) + description = fields.String(validate=validate.Length(max=_DESCRIPTION_MAX)) + categories = fields.List( + fields.String(validate=validate.Length(min=1, max=_NAME_MAX)), + validate=validate.Length(min=1), + ) + active = fields.Boolean() + + class Meta: + """Reject unknown fields.""" + + unknown = RAISE + + +class CategoryCreateSchema(Schema): + """Validates POST /categories bodies.""" + + name = fields.String( + required=True, validate=validate.Length(min=1, max=_NAME_MAX)) + description = fields.String( + load_default='', validate=validate.Length(max=_DESCRIPTION_MAX)) + + class Meta: + """Reject unknown fields.""" + + unknown = RAISE + + +class CategoryUpdateSchema(Schema): + """Validates PATCH /categories/{id} bodies; every field optional.""" + + name = fields.String(validate=validate.Length(min=1, max=_NAME_MAX)) + description = fields.String(validate=validate.Length(max=_DESCRIPTION_MAX)) + + class Meta: + """Reject unknown fields.""" + + unknown = RAISE diff --git a/mod_api/schemas/system.py b/mod_api/schemas/system.py new file mode 100644 index 00000000..e4c3bd84 --- /dev/null +++ b/mod_api/schemas/system.py @@ -0,0 +1,50 @@ +"""Request schemas for the platform configuration endpoints.""" + +from marshmallow import RAISE, Schema, fields, validate + + +class MaintenanceUpdateSchema(Schema): + """Validates PATCH /system/maintenance/{platform} bodies.""" + + disabled = fields.Boolean(required=True) + + class Meta: + """Reject unknown fields.""" + + unknown = RAISE + + +class BlockedUserCreateSchema(Schema): + """Validates POST /system/blocked-users bodies.""" + + # The numeric GitHub account id, not the login: logins can be changed and + # reused, which would silently unblock somebody. + user_id = fields.Integer(required=True, validate=validate.Range(min=1)) + comment = fields.String(load_default='', validate=validate.Length(max=1024)) + + class Meta: + """Reject unknown fields.""" + + unknown = RAISE + + +class ForbiddenExtensionCreateSchema(Schema): + """Validates POST /system/forbidden-extensions bodies.""" + + # Stored without the leading dot, matching how upload validation looks it + # up. Letters and digits only, so a pattern can never be smuggled in. + extension = fields.String( + required=True, + validate=[ + validate.Length(min=1, max=32), + validate.Regexp( + r'^[A-Za-z0-9]+$', + error='extension must be alphanumeric, without a leading dot', + ), + ], + ) + + class Meta: + """Reject unknown fields.""" + + unknown = RAISE diff --git a/openapi-ci-api.yaml b/openapi-ci-api.yaml index e30162dd..3e248652 100644 --- a/openapi-ci-api.yaml +++ b/openapi-ci-api.yaml @@ -49,6 +49,8 @@ tags: description: Structured errors and raw log access - name: System description: Health, queue, and artifacts + - name: Users + description: Platform accounts and role administration # # SECURITY NOTES (implementers must read) @@ -295,6 +297,34 @@ paths: default: $ref: "#/components/responses/Error" + /auth/me: + get: + tags: [Auth] + summary: Identify the account behind the current token + operationId: getCurrentUser + description: > + A token does not carry the account's role, so a client cannot infer + what the caller is allowed to do. Clients gate their interface on + this instead of guessing, and the granted scopes are echoed so a + client can also hide what this token could never call. Requires no + particular scope: it reports on the caller and discloses nothing + another endpoint would withhold. + security: + - bearerAuth: [] + responses: + "200": + description: The authenticated account + content: + application/json: + schema: + $ref: "#/components/schemas/CurrentUser" + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + # RUNS /runs: @@ -788,6 +818,41 @@ paths: default: $ref: "#/components/responses/Error" + /samples/{sample_id}/details: + get: + tags: [Samples] + summary: Get the full detail payload for a media sample + operationId: getSampleDetails + description: > + Extends the summary payload with the upload record, any extra files + attached to the sample, and the parsed MediaInfo tree. Media info is + best effort: a sample whose XML is missing or unparseable reports + null rather than failing the response. Unlike the classic page this + never regenerates the XML, because a GET must not write to the + sample repository. + security: + - bearerAuth: [] + x-required-scope: runs:read + parameters: + - $ref: "#/components/parameters/SampleId" + responses: + "200": + description: Full media sample detail + content: + application/json: + schema: + $ref: "#/components/schemas/SampleDetails" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + /samples/{sample_id}/history: get: tags: [Samples] @@ -895,6 +960,682 @@ paths: default: $ref: "#/components/responses/Error" + post: + tags: [Samples] + summary: Create a regression test definition + operationId: createRegressionTest + description: > + Admin or contributor only. The test is created inactive unless the + body says otherwise, so it cannot join a CI suite before someone has + seen the output it actually produces on a verification run. Every + named category must already exist; an unknown name rejects the whole + request rather than silently dropping it. + security: + - bearerAuth: [] + x-required-scope: runs:write + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RegressionTestCreateRequest" + responses: + "201": + description: The created regression test + content: + application/json: + schema: + $ref: "#/components/schemas/RegressionTest" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /categories: + get: + tags: [Samples] + summary: List regression test categories + operationId: listCategories + security: + - bearerAuth: [] + x-required-scope: runs:read + parameters: + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + responses: + "200": + description: Paginated categories, ordered by name + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Page" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/Category" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + post: + tags: [Samples] + summary: Create a category + operationId: createCategory + description: Admin or contributor only. Names are unique. + security: + - bearerAuth: [] + x-required-scope: runs:write + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CategoryCreateRequest" + responses: + "201": + description: The created category + content: + application/json: + schema: + $ref: "#/components/schemas/Category" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "409": + $ref: "#/components/responses/Conflict" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /categories/{category_id}: + patch: + tags: [Samples] + summary: Rename or re-describe a category + operationId: updateCategory + description: Admin or contributor only. An empty body is rejected. + security: + - bearerAuth: [] + x-required-scope: runs:write + parameters: + - $ref: "#/components/parameters/CategoryId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CategoryUpdateRequest" + responses: + "200": + description: The updated category + content: + application/json: + schema: + $ref: "#/components/schemas/Category" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + delete: + tags: [Samples] + summary: Delete a category + operationId: deleteCategory + description: > + Admin or contributor only. A category still attached to regression + tests is refused with 409 and a count, because dropping it would + change which tests a suite selection picks up. Detach the tests + first by PATCHing their categories. + security: + - bearerAuth: [] + x-required-scope: runs:write + parameters: + - $ref: "#/components/parameters/CategoryId" + responses: + "200": + description: The category was deleted + content: + application/json: + schema: + $ref: "#/components/schemas/DeletedResource" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /regression-tests/{regression_test_id}: + get: + tags: [Samples] + summary: Get one regression test with its baselines + operationId: getRegressionTest + description: > + Adds the expected outputs and the alternative hashes accepted as + variants, which the list endpoint omits because they multiply the + payload for every row. + security: + - bearerAuth: [] + x-required-scope: runs:read + parameters: + - $ref: "#/components/parameters/RegressionTestId" + responses: + "200": + description: Regression test detail + content: + application/json: + schema: + $ref: "#/components/schemas/RegressionTestDetail" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + delete: + tags: [Samples] + summary: Delete a regression test + operationId: deleteRegressionTest + description: > + Admin or contributor only. A test that has already run is refused + with 409 and the number of results referencing it, because deleting + it would erase evidence of past regressions. Retire such a test with + PATCH active=false instead. + security: + - bearerAuth: [] + x-required-scope: runs:write + parameters: + - $ref: "#/components/parameters/RegressionTestId" + responses: + "200": + description: The regression test was deleted + content: + application/json: + schema: + $ref: "#/components/schemas/DeletedResource" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + patch: + tags: [Samples] + summary: Update part of a regression test definition + operationId: updateRegressionTest + description: > + Admin or contributor only. Only the fields present in the body are + written, so two clients editing different fields cannot clobber each + other. An empty body is rejected. + security: + - bearerAuth: [] + x-required-scope: runs:write + parameters: + - $ref: "#/components/parameters/RegressionTestId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RegressionTestUpdateRequest" + responses: + "200": + description: The updated regression test + content: + application/json: + schema: + $ref: "#/components/schemas/RegressionTest" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + # PLATFORM CONFIGURATION + + /system/maintenance: + get: + tags: [System] + summary: Report maintenance state for every platform + operationId: getMaintenance + description: > + A platform that has never been put into maintenance is reported as + disabled=false rather than omitted, so a client always receives one + entry per platform. Keyed on platforms rather than the data + collection envelope: the list comes from the platform enum, not a + growable table, so there is nothing to paginate. Admin only, like + the rest of the platform configuration. + security: + - bearerAuth: [] + x-required-scope: system:read + responses: + "200": + description: Maintenance state per platform + content: + application/json: + schema: + type: object + properties: + platforms: + type: array + items: + $ref: "#/components/schemas/MaintenanceState" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /system/maintenance/{platform}: + patch: + tags: [System] + summary: Pause or resume CI for one platform + operationId: updateMaintenance + description: > + Admin only. While a platform is disabled new runs still queue; they + are simply not handed to a VM until it is resumed. + security: + - bearerAuth: [] + x-required-scope: system:write + parameters: + - $ref: "#/components/parameters/PlatformPath" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MaintenanceUpdateRequest" + responses: + "200": + description: The updated maintenance state + content: + application/json: + schema: + $ref: "#/components/schemas/MaintenanceState" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /system/blocked-users: + get: + tags: [System] + summary: List GitHub accounts blocked from triggering CI runs + operationId: listBlockedUsers + description: > + Admin only. The blocklist names GitHub accounts, so it is not + readable with system:read alone. + security: + - bearerAuth: [] + x-required-scope: system:read + parameters: + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + responses: + "200": + description: Paginated blocked GitHub accounts + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Page" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/BlockedUser" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + post: + tags: [System] + summary: Block a GitHub account from triggering CI runs + operationId: createBlockedUser + description: > + Admin only. Keyed on the numeric GitHub account id rather than the + login, because a login can be changed and reused, which would + silently unblock somebody. + security: + - bearerAuth: [] + x-required-scope: system:write + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/BlockedUserCreateRequest" + responses: + "201": + description: The account is now blocked + content: + application/json: + schema: + $ref: "#/components/schemas/BlockedUser" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "409": + $ref: "#/components/responses/Conflict" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /system/blocked-users/{user_id}: + delete: + tags: [System] + summary: Unblock a GitHub account + operationId: deleteBlockedUser + description: Admin only. + security: + - bearerAuth: [] + x-required-scope: system:write + parameters: + - $ref: "#/components/parameters/BlockedUserId" + responses: + "200": + description: The account is no longer blocked + content: + application/json: + schema: + type: object + properties: + user_id: + type: integer + deleted: + type: boolean + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /system/forbidden-extensions: + get: + tags: [System] + summary: List file extensions rejected on upload + operationId: listForbiddenExtensions + description: Admin only, like the rest of the platform configuration. + security: + - bearerAuth: [] + x-required-scope: system:read + parameters: + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + responses: + "200": + description: Paginated forbidden extensions, without a leading dot + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Page" + - type: object + properties: + data: + type: array + items: + type: string + maxLength: 32 + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + post: + tags: [System] + summary: Forbid a file extension on upload + operationId: createForbiddenExtension + description: > + Admin only. Stored lower-cased and without a leading dot, matching + how upload validation looks it up. + security: + - bearerAuth: [] + x-required-scope: system:write + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ForbiddenExtensionCreateRequest" + responses: + "201": + description: The extension is now forbidden + content: + application/json: + schema: + type: object + properties: + extension: + type: string + maxLength: 32 + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "409": + $ref: "#/components/responses/Conflict" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /system/forbidden-extensions/{extension}: + delete: + tags: [System] + summary: Allow a file extension to be uploaded again + operationId: deleteForbiddenExtension + description: Admin only. A leading dot in the path is tolerated. + security: + - bearerAuth: [] + x-required-scope: system:write + parameters: + - $ref: "#/components/parameters/ExtensionPath" + responses: + "200": + description: The extension is no longer forbidden + content: + application/json: + schema: + type: object + properties: + extension: + type: string + maxLength: 32 + deleted: + type: boolean + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + # USERS + + /users: + get: + tags: [Users] + summary: List platform users + operationId: listUsers + description: > + Admin only. Paginated like every other collection here, because the + platform has hundreds of accounts. Credentials and GitHub tokens are + never included. + security: + - bearerAuth: [] + x-required-scope: tokens:manage + parameters: + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + responses: + "200": + description: Paginated platform users + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Page" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/PlatformUser" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /users/{user_id}: + patch: + tags: [Users] + summary: Change a user's role + operationId: updateUserRole + description: > + Admin only. An admin cannot change their own role: demoting the last + admin through this endpoint would leave nobody able to undo it, so + that request is refused with 403. + security: + - bearerAuth: [] + x-required-scope: tokens:manage + parameters: + - $ref: "#/components/parameters/UserId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RoleUpdateRequest" + responses: + "200": + description: The updated user + content: + application/json: + schema: + $ref: "#/components/schemas/PlatformUser" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + description: > + The caller is not an admin, or attempted to change their own role. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + code: forbidden + message: You cannot change your own role. + details: {} + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + # RESULTS /runs/{run_id}/samples/{sample_id}/regression-tests/{regression_id}/outputs/{output_id}/expected: @@ -1625,6 +2366,51 @@ components: type: integer minimum: 1 + UserId: + name: user_id + in: path + required: true + description: Numeric platform user ID + schema: + type: integer + minimum: 1 + + CategoryId: + name: category_id + in: path + required: true + description: Numeric category ID + schema: + type: integer + minimum: 1 + + BlockedUserId: + name: user_id + in: path + required: true + description: Numeric GitHub account ID (not the login) + schema: + type: integer + minimum: 1 + + PlatformPath: + name: platform + in: path + required: true + description: CI platform + schema: + type: string + enum: [linux, windows] + + ExtensionPath: + name: extension + in: path + required: true + description: File extension, with or without a leading dot + schema: + type: string + maxLength: 33 + RunStatus: name: status in: query @@ -1778,6 +2564,24 @@ components: message: Run 9317 not found. details: resource: run + + Conflict: + description: > + The request clashes with existing state — a duplicate name, or a + deletion that would discard records still referenced elsewhere. + Where the conflict is about dependants, details carries their count + and the request can be repeated with ?force=true. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + code: conflict + message: > + Regression test 42 has 118 historical result(s). Pass + ?force=true to delete it and them. + details: + result_count: 118 id: 9317 UnprocessableEntity: @@ -1932,11 +2736,11 @@ components: maxLength: 50 scopes: type: array - maxItems: 6 + maxItems: 7 uniqueItems: true items: type: string - enum: [runs:read, runs:write, results:read, baselines:write, system:read, tokens:manage] + enum: [runs:read, runs:write, results:read, baselines:write, system:read, system:write, tokens:manage] created_at: type: string format: date-time @@ -1981,20 +2785,23 @@ components: default: 7 scopes: type: array - maxItems: 6 + maxItems: 7 uniqueItems: true default: [runs:read, results:read] items: type: string - enum: [runs:read, runs:write, results:read, baselines:write, system:read, tokens:manage] + enum: [runs:read, runs:write, results:read, baselines:write, system:read, system:write, tokens:manage] description: > Requested scopes. Grant only what the client needs. runs:read — list and inspect runs, samples, history. - runs:write — trigger and cancel runs. + runs:write — trigger and cancel runs, and edit the regression suite. results:read — access expected/actual output, diffs, errors, logs. baselines:write — approve new expected baselines. - system:read — queue, infrastructure errors, stack traces, artifacts. - tokens:manage — list and revoke API tokens. + system:read — queue, infrastructure errors, stack traces, artifacts, + and the platform configuration. + system:write — change the platform configuration: maintenance mode, + blocked CI users, forbidden upload extensions. Admin only. + tokens:manage — list and revoke API tokens, and administer users. AuthToken: type: object @@ -2017,7 +2824,7 @@ components: uniqueItems: true items: type: string - enum: [runs:read, runs:write, results:read, baselines:write, system:read, tokens:manage] + enum: [runs:read, runs:write, results:read, baselines:write, system:read, system:write, tokens:manage] expires_at: type: string format: date-time @@ -2267,6 +3074,361 @@ components: type: boolean description: True if at least one active regression test references this sample. + DeletedResource: + type: object + required: [deleted] + properties: + id: + type: integer + minimum: 1 + deleted: + type: boolean + + Category: + type: object + required: [id, name] + properties: + id: + type: integer + minimum: 1 + name: + type: string + maxLength: 64 + description: + type: string + maxLength: 1024 + test_count: + type: integer + minimum: 0 + description: How many regression tests reference this category. + + CategoryCreateRequest: + type: object + additionalProperties: false + required: [name] + properties: + name: + type: string + minLength: 1 + maxLength: 64 + description: + type: string + default: "" + maxLength: 1024 + + CategoryUpdateRequest: + type: object + additionalProperties: false + minProperties: 1 + description: Only the fields present are written; an empty body is rejected. + properties: + name: + type: string + minLength: 1 + maxLength: 64 + description: + type: string + maxLength: 1024 + + RegressionTestDetail: + allOf: + - $ref: "#/components/schemas/RegressionTest" + - type: object + properties: + outputs: + type: array + maxItems: 100 + description: Expected outputs, with the hashes accepted as variants. + items: + type: object + properties: + id: + type: integer + minimum: 1 + correct: + type: string + maxLength: 128 + correct_extension: + type: string + maxLength: 64 + expected_filename: + type: string + maxLength: 255 + nullable: true + ignore: + type: boolean + variants: + type: array + maxItems: 100 + items: + type: string + maxLength: 128 + + MaintenanceState: + type: object + required: [platform, disabled] + properties: + platform: + type: string + enum: [linux, windows] + disabled: + type: boolean + description: > + True while the platform is paused. Runs still queue; they are + just not handed to a VM. + + MaintenanceUpdateRequest: + type: object + additionalProperties: false + required: [disabled] + properties: + disabled: + type: boolean + + BlockedUser: + type: object + required: [user_id] + properties: + user_id: + type: integer + minimum: 1 + description: Numeric GitHub account ID. + comment: + type: string + maxLength: 1024 + + BlockedUserCreateRequest: + type: object + additionalProperties: false + required: [user_id] + properties: + user_id: + type: integer + minimum: 1 + comment: + type: string + default: "" + maxLength: 1024 + + ForbiddenExtensionCreateRequest: + type: object + additionalProperties: false + required: [extension] + properties: + extension: + type: string + minLength: 1 + maxLength: 32 + pattern: "^[A-Za-z0-9]+$" + description: Alphanumeric, without a leading dot. + + CurrentUser: + type: object + required: [user_id, email, role, scopes] + properties: + user_id: + type: integer + minimum: 1 + name: + type: string + maxLength: 255 + email: + type: string + format: email + maxLength: 255 + role: + type: string + enum: [admin, contributor, tester, user] + scopes: + type: array + maxItems: 6 + items: + type: string + maxLength: 50 + + PlatformUser: + type: object + required: [user_id, email, role] + properties: + user_id: + type: integer + minimum: 1 + name: + type: string + maxLength: 255 + email: + type: string + format: email + maxLength: 255 + role: + type: string + enum: [admin, contributor, tester, user] + github_linked: + type: boolean + github_login: + type: string + maxLength: 255 + nullable: true + + RoleUpdateRequest: + type: object + additionalProperties: false + required: [role] + properties: + role: + type: string + enum: [admin, contributor, tester, user] + + SampleDetails: + type: object + required: [sample_id, sha, extension, original_name] + properties: + sample_id: + type: integer + minimum: 1 + sha: + type: string + maxLength: 128 + extension: + type: string + maxLength: 64 + original_name: + type: string + maxLength: 255 + filename: + type: string + maxLength: 255 + tags: + type: array + maxItems: 50 + items: + type: string + maxLength: 100 + upload: + nullable: true + description: The upload record, or null when the sample predates one. + type: object + properties: + platform: + type: string + maxLength: 50 + nullable: true + parameters: + type: string + maxLength: 1024 + notes: + type: string + maxLength: 4096 + version: + type: string + maxLength: 50 + nullable: true + version_released: + type: string + format: date-time + nullable: true + extra_files: + type: array + maxItems: 100 + items: + type: object + properties: + id: + type: integer + minimum: 1 + original_name: + type: string + maxLength: 255 + extension: + type: string + maxLength: 64 + media_info: + nullable: true + description: > + Parsed MediaInfo tree, or null when the XML is absent or cannot + be parsed. Shape mirrors the source document and is not fixed. + type: array + items: + type: object + + RegressionTestCreateRequest: + type: object + additionalProperties: false + required: [sample_id, command, categories] + properties: + sample_id: + type: integer + minimum: 1 + command: + type: string + minLength: 1 + maxLength: 500 + input_type: + type: string + default: file + enum: [file, stdin, udp] + output_type: + type: string + default: file + enum: [file, "null", stdout, tcp, cea708, multiprogram, report] + expected_rc: + type: integer + default: 0 + minimum: 0 + maximum: 255 + description: + type: string + default: "" + maxLength: 1024 + categories: + type: array + minItems: 1 + maxItems: 50 + items: + type: string + minLength: 1 + maxLength: 64 + active: + type: boolean + default: false + description: > + Left false so a new test cannot join a CI suite before its + output has been verified on a run. + + RegressionTestUpdateRequest: + type: object + additionalProperties: false + minProperties: 1 + description: Only the fields present are written; an empty body is rejected. + properties: + command: + type: string + minLength: 1 + maxLength: 500 + input_type: + type: string + enum: [file, stdin, udp] + output_type: + type: string + enum: [file, "null", stdout, tcp, cea708, multiprogram, report] + expected_rc: + type: integer + minimum: 0 + maximum: 255 + description: + type: string + maxLength: 1024 + categories: + type: array + minItems: 1 + maxItems: 50 + items: + type: string + minLength: 1 + maxLength: 64 + active: + type: boolean + RegressionTest: type: object required: [regression_test_id, sample_id, command] diff --git a/scripts/verify_schemathesis.py b/scripts/verify_schemathesis.py index 4201f8bc..c40339c5 100644 --- a/scripts/verify_schemathesis.py +++ b/scripts/verify_schemathesis.py @@ -60,14 +60,21 @@ # Schema loading # --------------------------------------------------------------------------- -# Base schema used for the broad fuzz test — excludes destructive auth routes. +# Base schema used for the broad fuzz test — excludes destructive routes. +# The administration deletes and the maintenance switch are excluded for the +# same reason as the auth ones: fuzzing them mutates the environment the rest +# of the run depends on, and pausing a platform would stall CI outright. schema = schemathesis.openapi.from_path("openapi-ci-api.yaml") schema.base_url = "/api/v1" schema.app = app schema = ( - schema.exclude(path="/auth/tokens/current").exclude( - path="/auth/tokens/{token_id}" - ) + schema.exclude(path="/auth/tokens/current") + .exclude(path="/auth/tokens/{token_id}") + .exclude(path="/system/maintenance/{platform}", method="PATCH") + .exclude(path="/system/blocked-users/{user_id}", method="DELETE") + .exclude(path="/system/forbidden-extensions/{extension}", method="DELETE") + .exclude(path="/regression-tests/{regression_test_id}", method="DELETE") + .exclude(path="/categories/{category_id}", method="DELETE") ) # Scoped sub-schemas used by per-endpoint targeted tests. @@ -181,6 +188,7 @@ def auth_token(): "results:read", "baselines:write", "system:read", + "system:write", "tokens:manage", ], ) diff --git a/tests/api/test_routes_auth.py b/tests/api/test_routes_auth.py index 18ab72c4..33357a87 100644 --- a/tests/api/test_routes_auth.py +++ b/tests/api/test_routes_auth.py @@ -346,3 +346,109 @@ def test_revoke_specific_token_already_revoked(self): headers={ 'Authorization': f'Bearer {admin_token}'}) self.assertEqual(res2.status_code, 204) + + def test_get_current_user_returns_role_and_scopes(self): + token = self.get_token( + 'auth_user@local.com', 'userpass123', 'me_tok', + scopes=['runs:read']).json['token'] + + res = self.client.get( + '/api/v1/auth/me', + headers={'Authorization': f'Bearer {token}'}) + + self.assertEqual(res.status_code, 200) + self.assertEqual(res.json['user_id'], self.user_id) + self.assertEqual(res.json['email'], 'auth_user@local.com') + # The role comes from the account, never from what the client asked + # for; this is what the web console gates its UI on. + self.assertEqual(res.json['role'], 'contributor') + self.assertEqual(res.json['scopes'], ['runs:read']) + + def test_get_current_user_reports_admin_role(self): + token = self.get_token( + 'auth_admin@local.com', 'adminpass123', 'me_admin', + scopes=['runs:read']).json['token'] + + res = self.client.get( + '/api/v1/auth/me', + headers={'Authorization': f'Bearer {token}'}) + + self.assertEqual(res.status_code, 200) + self.assertEqual(res.json['role'], 'admin') + + def test_get_current_user_requires_a_token(self): + res = self.client.get('/api/v1/auth/me') + self.assertEqual(res.status_code, 401) + + def _admin_token(self, name='usr_admin'): + return self.get_token('auth_admin@local.com', 'adminpass123', name, + scopes=['tokens:manage']).json['token'] + + def _patch_user(self, token, user_id, body): + return self.client.patch( + f'/api/v1/users/{user_id}', + data=json.dumps(body), + content_type='application/json', + headers={'Authorization': f'Bearer {token}'}) + + def test_list_users(self): + res = self.client.get( + '/api/v1/users', + headers={'Authorization': f'Bearer {self._admin_token()}'}) + + self.assertEqual(res.status_code, 200) + row = next(r for r in res.json['data'] + if r['email'] == 'auth_admin@local.com') + self.assertEqual(row['role'], 'admin') + self.assertFalse(row['github_linked']) + # Credentials must never appear in the payload. + self.assertNotIn('password', row) + self.assertNotIn('github_token', row) + + def test_list_users_is_paginated(self): + res = self.client.get( + '/api/v1/users?limit=1&offset=0', + headers={'Authorization': f'Bearer {self._admin_token("usr_p")}'}) + + self.assertEqual(res.status_code, 200) + self.assertEqual(len(res.json['data']), 1) + # total counts every user, not just the page, so a client knows to + # keep paging instead of silently seeing the first page only. + self.assertGreaterEqual(res.json['pagination']['total'], 2) + + def test_list_users_forbidden_for_contributor(self): + token = self.get_token('auth_user@local.com', 'userpass123', + 'usr_c', scopes=['runs:read']).json['token'] + res = self.client.get( + '/api/v1/users', headers={'Authorization': f'Bearer {token}'}) + self.assertEqual(res.status_code, 403) + + def test_update_user_role(self): + res = self._patch_user(self._admin_token('usr_up'), self.user_id, + {'role': 'user'}) + + self.assertEqual(res.status_code, 200) + self.assertEqual(res.json['role'], 'user') + self.assertEqual( + User.query.filter(User.id == self.user_id).first().role, Role.user) + + def test_update_user_role_rejects_self(self): + admin_id = User.query.filter( + User.email == 'auth_admin@local.com').first().id + res = self._patch_user(self._admin_token('usr_self'), admin_id, + {'role': 'user'}) + + # Demoting yourself would leave nobody able to undo it. + self.assertEqual(res.status_code, 403) + self.assertEqual( + User.query.filter(User.id == admin_id).first().role, Role.admin) + + def test_update_user_role_invalid_role(self): + res = self._patch_user(self._admin_token('usr_bad'), self.user_id, + {'role': 'superuser'}) + self.assertEqual(res.status_code, 400) + + def test_update_user_role_unknown_user(self): + res = self._patch_user(self._admin_token('usr_404'), 999999, + {'role': 'user'}) + self.assertEqual(res.status_code, 404) diff --git a/tests/api/test_routes_regression_tests.py b/tests/api/test_routes_regression_tests.py new file mode 100644 index 00000000..3e70cc4b --- /dev/null +++ b/tests/api/test_routes_regression_tests.py @@ -0,0 +1,357 @@ +import json + +from flask import g + +from mod_api.middleware.rate_limit import _rate_limit_store +from mod_auth.models import Role, User +from mod_regression.models import (Category, InputType, OutputType, + RegressionTest, RegressionTestOutput, + RegressionTestOutputFiles) +from mod_sample.models import Sample +from mod_test.models import TestResult +from tests.api.base import ApiTestCase + + +class TestRoutesRegressionTests(ApiTestCase): + def setUp(self): + super().setUp() + self.setup_run_data('rtw') + + g.db.add(User('testcontrib_rtw', Role.contributor, + 'rtw_contrib@local.com', + User.generate_hash('contribpass123'))) + g.db.commit() + + sample = Sample('rtw_sha', 'ts', 'rtw_sample') + g.db.add(sample) + g.db.commit() + self.sample_id = sample.id + + self.category = Category('Broadcast', 'Broadcast streams') + free_category = Category('Unused', 'Nothing points here') + g.db.add_all([self.category, free_category]) + g.db.commit() + self.category_id = self.category.id + self.free_category_id = free_category.id + + existing = RegressionTest( + self.sample_id, 'original command', InputType.file, + OutputType.file, None, 0) + g.db.add(existing) + g.db.commit() + existing.categories = [self.category] + g.db.commit() + self.existing_id = existing.id + + _rate_limit_store.clear() + + def _admin(self, name='rt_admin', scopes=('runs:read', 'runs:write')): + token = self.get_token('rtw_admin@local.com', 'adminpass123', name, + scopes=list(scopes)) + return {'Authorization': f'Bearer {token}'} + + def _as(self, email, password, name, scopes): + token = self.get_token(email, password, name, scopes=scopes) + return {'Authorization': f'Bearer {token}'} + + def _write(self, method, path, headers, body): + return getattr(self.client, method)( + f'/api/v1{path}', data=json.dumps(body), + content_type='application/json', headers=headers) + + # ---- regression tests: create -------------------------------------- + + def test_create_regression_test(self): + res = self._write('post', '/regression-tests', self._admin(), { + 'sample_id': self.sample_id, + 'command': '--autoprogram --out=srt', + 'categories': ['Broadcast'], + 'description': 'checks srt output', + }) + + self.assertEqual(res.status_code, 201) + self.assertEqual(res.json['command'], '--autoprogram --out=srt') + self.assertEqual(res.json['categories'], ['Broadcast']) + # A new test must not join the CI suite until its output is verified. + self.assertFalse(res.json['active']) + + created = RegressionTest.query.filter( + RegressionTest.id == res.json['regression_test_id']).first() + self.assertEqual(created.sample_id, self.sample_id) + + def test_create_regression_test_active_when_requested(self): + res = self._write('post', '/regression-tests', self._admin(), { + 'sample_id': self.sample_id, 'command': 'cmd', + 'categories': ['Broadcast'], 'active': True}) + self.assertEqual(res.status_code, 201) + self.assertTrue(res.json['active']) + + def test_create_regression_test_as_contributor(self): + headers = self._as('rtw_contrib@local.com', 'contribpass123', + 'rt_contrib', ['runs:write']) + res = self._write('post', '/regression-tests', headers, { + 'sample_id': self.sample_id, 'command': 'cmd', + 'categories': ['Broadcast']}) + self.assertEqual(res.status_code, 201) + + def test_create_regression_test_forbidden_for_plain_user(self): + # Holding runs:write is not enough; the role is checked separately. + headers = self._as('rtw_user@local.com', 'userpass123', 'rt_user', + ['runs:write']) + res = self._write('post', '/regression-tests', headers, { + 'sample_id': self.sample_id, 'command': 'cmd', + 'categories': ['Broadcast']}) + self.assertEqual(res.status_code, 403) + + def test_create_regression_test_requires_write_scope(self): + res = self._write( + 'post', '/regression-tests', + self._admin('rt_noscope', scopes=['runs:read']), + {'sample_id': self.sample_id, 'command': 'cmd', + 'categories': ['Broadcast']}) + self.assertEqual(res.status_code, 403) + + def test_create_regression_test_unknown_sample(self): + res = self._write('post', '/regression-tests', self._admin(), { + 'sample_id': 999999, 'command': 'cmd', + 'categories': ['Broadcast']}) + self.assertEqual(res.status_code, 404) + + def test_create_regression_test_unknown_category(self): + res = self._write('post', '/regression-tests', self._admin(), { + 'sample_id': self.sample_id, 'command': 'cmd', + 'categories': ['Broadcast', 'Nope']}) + + # The whole request is rejected rather than silently dropping the + # category the client believed it had set. + self.assertEqual(res.status_code, 400) + self.assertEqual(res.json['details']['fields']['categories'], ['Nope']) + + def test_create_regression_test_rejects_bad_output_type(self): + res = self._write('post', '/regression-tests', self._admin(), { + 'sample_id': self.sample_id, 'command': 'cmd', + 'categories': ['Broadcast'], 'output_type': 'not-a-type'}) + self.assertEqual(res.status_code, 400) + + def test_create_regression_test_requires_categories(self): + res = self._write('post', '/regression-tests', self._admin(), { + 'sample_id': self.sample_id, 'command': 'cmd'}) + self.assertEqual(res.status_code, 400) + + # ---- regression tests: update -------------------------------------- + + def test_update_regression_test(self): + res = self._write( + 'patch', f'/regression-tests/{self.existing_id}', self._admin(), + {'command': 'updated command', 'description': 'new description'}) + + self.assertEqual(res.status_code, 200) + self.assertEqual(res.json['command'], 'updated command') + self.assertEqual(res.json['description'], 'new description') + self.assertEqual(RegressionTest.query.filter( + RegressionTest.id == self.existing_id).first().command, + 'updated command') + + def test_update_regression_test_leaves_untouched_fields(self): + res = self._write( + 'patch', f'/regression-tests/{self.existing_id}', self._admin(), + {'description': 'only this'}) + + self.assertEqual(res.status_code, 200) + # command was not in the body, so it must survive unchanged. + self.assertEqual(res.json['command'], 'original command') + + def test_update_regression_test_toggles_active(self): + res = self._write( + 'patch', f'/regression-tests/{self.existing_id}', self._admin(), + {'active': False}) + self.assertEqual(res.status_code, 200) + self.assertFalse(res.json['active']) + + def test_update_regression_test_replaces_categories(self): + res = self._write( + 'patch', f'/regression-tests/{self.existing_id}', self._admin(), + {'categories': ['Unused']}) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.json['categories'], ['Unused']) + + def test_update_regression_test_unknown_category(self): + res = self._write( + 'patch', f'/regression-tests/{self.existing_id}', self._admin(), + {'categories': ['Missing']}) + self.assertEqual(res.status_code, 400) + + def test_update_regression_test_empty_body(self): + res = self._write( + 'patch', f'/regression-tests/{self.existing_id}', self._admin(), + {}) + self.assertEqual(res.status_code, 400) + + def test_update_regression_test_not_found(self): + res = self._write('patch', '/regression-tests/999999', self._admin(), + {'command': 'x'}) + self.assertEqual(res.status_code, 404) + + # ---- regression tests: detail and delete --------------------------- + + def test_get_regression_test_detail(self): + output = RegressionTestOutput( + self.existing_id, 'expected_hash', '.srt', 'expected_name') + g.db.add(output) + g.db.commit() + g.db.add(RegressionTestOutputFiles('variant_hash', output.id)) + g.db.commit() + + res = self.client.get( + f'/api/v1/regression-tests/{self.existing_id}', + headers=self._as('rtw_user@local.com', 'userpass123', 'rt_detail', + ['runs:read'])) + + self.assertEqual(res.status_code, 200) + self.assertEqual(res.json['regression_test_id'], self.existing_id) + self.assertEqual(res.json['outputs'][0]['correct'], 'expected_hash') + # Alternative accepted hashes travel with the baseline. + self.assertEqual(res.json['outputs'][0]['variants'], ['variant_hash']) + + def test_get_regression_test_detail_not_found(self): + res = self.client.get( + '/api/v1/regression-tests/999999', + headers=self._as('rtw_user@local.com', 'userpass123', 'rt_404', + ['runs:read'])) + self.assertEqual(res.status_code, 404) + + def test_delete_regression_test_without_history(self): + res = self.client.delete( + f'/api/v1/regression-tests/{self.existing_id}', + headers=self._admin('rt_del')) + + self.assertEqual(res.status_code, 200) + self.assertIsNone(RegressionTest.query.filter( + RegressionTest.id == self.existing_id).first()) + + def test_delete_regression_test_removes_its_outputs(self): + output = RegressionTestOutput(self.existing_id, 'h', '.srt', 'name') + g.db.add(output) + g.db.commit() + output_id = output.id + g.db.add(RegressionTestOutputFiles('variant', output_id)) + g.db.commit() + + res = self.client.delete( + f'/api/v1/regression-tests/{self.existing_id}', + headers=self._admin('rt_del2')) + + self.assertEqual(res.status_code, 200) + # Baselines and variants are RESTRICT-linked, so they must be gone + # too or the delete would have failed at the database. + self.assertIsNone(RegressionTestOutput.query.filter( + RegressionTestOutput.id == output_id).first()) + self.assertEqual(RegressionTestOutputFiles.query.filter_by( + regression_test_output_id=output_id).count(), 0) + + def test_delete_regression_test_with_history_refused(self): + g.db.add(TestResult(self.test_id, self.existing_id, 0, 0, 0)) + g.db.commit() + + res = self.client.delete( + f'/api/v1/regression-tests/{self.existing_id}', + headers=self._admin('rt_del3')) + + # Deleting would erase evidence of past regressions; retiring the + # test is a PATCH with active=false instead. + self.assertEqual(res.status_code, 409) + self.assertEqual(res.json['details']['result_count'], 1) + self.assertIsNotNone(RegressionTest.query.filter( + RegressionTest.id == self.existing_id).first()) + + def test_delete_regression_test_not_found(self): + res = self.client.delete('/api/v1/regression-tests/999999', + headers=self._admin('rt_del4')) + self.assertEqual(res.status_code, 404) + + # ---- categories ---------------------------------------------------- + + def test_list_categories(self): + res = self.client.get('/api/v1/categories', headers=self._admin()) + + self.assertEqual(res.status_code, 200) + names = [row['name'] for row in res.json['data']] + # Alphabetical, so a client can render the rail without sorting. + self.assertEqual(names, sorted(names)) + used = next(r for r in res.json['data'] if r['name'] == 'Broadcast') + self.assertEqual(used['test_count'], 1) + + def test_create_category(self): + res = self._write('post', '/categories', self._admin(), + {'name': 'Teletext', 'description': 'DVB teletext'}) + + self.assertEqual(res.status_code, 201) + self.assertEqual(res.json['name'], 'Teletext') + self.assertEqual(res.json['test_count'], 0) + self.assertIsNotNone( + Category.query.filter(Category.name == 'Teletext').first()) + + def test_create_category_duplicate_name(self): + res = self._write('post', '/categories', self._admin(), + {'name': 'Broadcast'}) + self.assertEqual(res.status_code, 409) + + def test_create_category_allows_contributor(self): + headers = self._as('rtw_contrib@local.com', 'contribpass123', 'cat_c', + ['runs:write']) + res = self._write('post', '/categories', headers, + {'name': 'Contributed'}) + self.assertEqual(res.status_code, 201) + + def test_update_category_renames(self): + res = self._write('patch', f'/categories/{self.free_category_id}', + self._admin(), {'name': 'Renamed'}) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.json['name'], 'Renamed') + + def test_update_category_to_existing_name(self): + res = self._write('patch', f'/categories/{self.free_category_id}', + self._admin(), {'name': 'Broadcast'}) + self.assertEqual(res.status_code, 409) + + def test_update_category_keeping_own_name_is_allowed(self): + # Re-sending the current name must not collide with itself. + res = self._write('patch', f'/categories/{self.free_category_id}', + self._admin(), + {'name': 'Unused', 'description': 'new text'}) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.json['description'], 'new text') + + def test_update_category_empty_body(self): + res = self._write('patch', f'/categories/{self.free_category_id}', + self._admin(), {}) + self.assertEqual(res.status_code, 400) + + def test_update_category_not_found(self): + res = self._write('patch', '/categories/999999', self._admin(), + {'name': 'x'}) + self.assertEqual(res.status_code, 404) + + def test_delete_unused_category(self): + res = self.client.delete( + f'/api/v1/categories/{self.free_category_id}', + headers=self._admin()) + + self.assertEqual(res.status_code, 200) + self.assertIsNone(Category.query.filter( + Category.id == self.free_category_id).first()) + + def test_delete_in_use_category_refused(self): + res = self.client.delete(f'/api/v1/categories/{self.category_id}', + headers=self._admin()) + + # Dropping it would change which tests a suite selection picks up. + self.assertEqual(res.status_code, 409) + self.assertEqual(res.json['details']['regression_test_count'], 1) + self.assertIsNotNone(Category.query.filter( + Category.id == self.category_id).first()) + + def test_delete_category_not_found(self): + res = self.client.delete('/api/v1/categories/999999', + headers=self._admin()) + self.assertEqual(res.status_code, 404) diff --git a/tests/api/test_routes_samples.py b/tests/api/test_routes_samples.py index f38727af..35b0c50a 100644 --- a/tests/api/test_routes_samples.py +++ b/tests/api/test_routes_samples.py @@ -282,3 +282,46 @@ def test_get_sample_history_invalid_status(self): headers={ 'Authorization': f'Bearer {token}'}) self.assertEqual(res.status_code, 400) + + def test_get_sample_details(self): + token = self.get_token('samp_user@local.com', 'userpass123', + 'det1', scopes=['runs:read']) + res = self.client.get( + f'/api/v1/samples/{self.sample_id}/details', + headers={'Authorization': f'Bearer {token}'}) + + self.assertEqual(res.status_code, 200) + self.assertEqual(res.json['sample_id'], self.sample_id) + self.assertEqual(res.json['original_name'], 'test_sample') + self.assertEqual(res.json['extra_files'], []) + # No upload row exists for this sample, and the media XML is absent + # in tests; both degrade to null rather than failing the response. + self.assertIsNone(res.json['upload']) + self.assertIsNone(res.json['media_info']) + + def test_get_sample_details_includes_upload_metadata(self): + from mod_upload.models import Platform, Upload + g.db.add(Upload(self.admin.id, self.sample_id, None, + Platform.linux, '--autoprogram', 'a note')) + g.db.commit() + + token = self.get_token('samp_user@local.com', 'userpass123', + 'det2', scopes=['runs:read']) + res = self.client.get( + f'/api/v1/samples/{self.sample_id}/details', + headers={'Authorization': f'Bearer {token}'}) + + self.assertEqual(res.status_code, 200) + self.assertEqual(res.json['upload']['platform'], 'linux') + self.assertEqual(res.json['upload']['parameters'], '--autoprogram') + self.assertEqual(res.json['upload']['notes'], 'a note') + # No CCExtractorVersion row is linked, so version reports null. + self.assertIsNone(res.json['upload']['version']) + + def test_get_sample_details_not_found(self): + token = self.get_token('samp_user@local.com', 'userpass123', + 'det3', scopes=['runs:read']) + res = self.client.get( + '/api/v1/samples/999999/details', + headers={'Authorization': f'Bearer {token}'}) + self.assertEqual(res.status_code, 404) diff --git a/tests/api/test_routes_system.py b/tests/api/test_routes_system.py index a0870275..66c86af7 100644 --- a/tests/api/test_routes_system.py +++ b/tests/api/test_routes_system.py @@ -7,7 +7,9 @@ from mod_api.middleware.rate_limit import _rate_limit_store from mod_auth.models import Role, User +from mod_ci.models import BlockedUsers, MaintenanceMode from mod_regression.models import RegressionTestOutput +from mod_sample.models import ForbiddenExtension from mod_test.models import Fork, Test, TestPlatform, TestResultFile, TestType from tests.api.base import ApiTestCase @@ -190,3 +192,187 @@ def test_safe_resolve_path_traversal(self): # Should return None for path traversal attempts self.assertIsNone(safe_resolve(base, '../../../etc/passwd')) self.assertIsNone(safe_resolve(base, '/etc/passwd')) + + +class TestRoutesPlatformConfig(ApiTestCase): + """Maintenance mode, blocked CI users, and forbidden upload extensions.""" + + def setUp(self): + super().setUp() + self.setup_run_data('adm') + g.db.add(BlockedUsers(4242, 'repeated abusive uploads')) + g.db.add(ForbiddenExtension('exe')) + g.db.commit() + _rate_limit_store.clear() + + def _admin(self, name='adm_tok', scopes=None): + token = self.get_token( + 'adm_admin@local.com', 'adminpass123', name, + scopes=scopes or ['system:read', 'system:write']) + return {'Authorization': f'Bearer {token}'} + + def _reader(self, name='adm_read'): + token = self.get_token('adm_user@local.com', 'userpass123', name, + scopes=['system:read']) + return {'Authorization': f'Bearer {token}'} + + def _write(self, method, path, headers, body): + return getattr(self.client, method)( + f'/api/v1{path}', data=json.dumps(body), + content_type='application/json', headers=headers) + + def test_plain_user_cannot_request_system_write(self): + # system:write reconfigures CI itself, so it is admin-only at the + # point a token is minted, not merely at the route. + res = self._write('post', '/auth/tokens', {}, { + 'email': 'adm_user@local.com', 'password': 'userpass123', + 'token_name': 'nope', 'scopes': ['system:write']}) + self.assertEqual(res.status_code, 403) + + def test_admin_can_request_every_scope(self): + # Guards the token schema's scope-count cap against the scope list. + res = self._write('post', '/auth/tokens', {}, { + 'email': 'adm_admin@local.com', 'password': 'adminpass123', + 'token_name': 'allscopes', + 'scopes': ['runs:read', 'runs:write', 'results:read', + 'baselines:write', 'system:read', 'system:write', + 'tokens:manage']}) + self.assertEqual(res.status_code, 201) + + def test_platform_config_reads_refused_for_non_admin(self): + # The blocklist names accounts and the rest is platform configuration, + # so holding system:read is not on its own enough to read any of it. + headers = self._reader() + for path in ('/system/maintenance', '/system/blocked-users', + '/system/forbidden-extensions'): + res = self.client.get(f'/api/v1{path}', headers=headers) + self.assertEqual(res.status_code, 403, f'{path} was readable') + + def test_get_maintenance_reports_every_platform(self): + res = self.client.get('/api/v1/system/maintenance', + headers=self._admin('adm_read_m')) + + self.assertEqual(res.status_code, 200) + self.assertEqual({row['platform'] for row in res.json['platforms']}, + set(TestPlatform.values())) + # Nothing stored yet, so every platform reads as running. + self.assertTrue( + all(row['disabled'] is False for row in res.json['platforms'])) + + def test_update_maintenance_creates_then_updates(self): + res = self._write('patch', '/system/maintenance/linux', + self._admin(), {'disabled': True}) + self.assertEqual(res.status_code, 200) + self.assertTrue(res.json['disabled']) + + # Toggling back must reuse the row rather than add a second one. + res = self._write('patch', '/system/maintenance/linux', + self._admin('adm_tok2'), {'disabled': False}) + self.assertEqual(res.status_code, 200) + self.assertFalse(res.json['disabled']) + self.assertEqual(MaintenanceMode.query.filter( + MaintenanceMode.platform == TestPlatform.linux).count(), 1) + + def test_update_maintenance_invalid_platform(self): + res = self._write('patch', '/system/maintenance/atari', + self._admin(), {'disabled': True}) + self.assertEqual(res.status_code, 400) + + def test_update_maintenance_requires_system_write(self): + res = self._write('patch', '/system/maintenance/linux', + self._admin('adm_ro', scopes=['system:read']), + {'disabled': True}) + self.assertEqual(res.status_code, 403) + + def test_list_blocked_users(self): + res = self.client.get('/api/v1/system/blocked-users', + headers=self._admin('adm_read_b')) + + self.assertEqual(res.status_code, 200) + self.assertEqual(res.json['data'][0]['user_id'], 4242) + self.assertEqual(res.json['data'][0]['comment'], + 'repeated abusive uploads') + + def test_block_user(self): + res = self._write('post', '/system/blocked-users', self._admin(), + {'user_id': 99, 'comment': 'spam'}) + + self.assertEqual(res.status_code, 201) + self.assertIsNotNone( + BlockedUsers.query.filter(BlockedUsers.user_id == 99).first()) + + def test_block_user_twice(self): + res = self._write('post', '/system/blocked-users', self._admin(), + {'user_id': 4242}) + self.assertEqual(res.status_code, 409) + + def test_block_user_rejects_login_instead_of_id(self): + # The model keys on the numeric GitHub id; a login would silently + # block nobody. + res = self._write('post', '/system/blocked-users', self._admin(), + {'user_id': 'octocat'}) + self.assertEqual(res.status_code, 400) + + def test_block_user_forbidden_for_non_admin(self): + res = self._write('post', '/system/blocked-users', + self._reader('adm_r2'), {'user_id': 7}) + self.assertEqual(res.status_code, 403) + + def test_unblock_user(self): + res = self.client.delete('/api/v1/system/blocked-users/4242', + headers=self._admin()) + + self.assertEqual(res.status_code, 200) + self.assertIsNone( + BlockedUsers.query.filter(BlockedUsers.user_id == 4242).first()) + + def test_unblock_user_not_blocked(self): + res = self.client.delete('/api/v1/system/blocked-users/1234', + headers=self._admin()) + self.assertEqual(res.status_code, 404) + + def test_list_forbidden_extensions(self): + res = self.client.get('/api/v1/system/forbidden-extensions', + headers=self._admin('adm_read_e')) + self.assertEqual(res.status_code, 200) + self.assertIn('exe', res.json['data']) + + def test_forbid_extension(self): + res = self._write('post', '/system/forbidden-extensions', + self._admin(), {'extension': 'BAT'}) + + self.assertEqual(res.status_code, 201) + # Stored lower-cased, since upload validation compares lower-cased. + self.assertEqual(res.json['extension'], 'bat') + self.assertIsNotNone(ForbiddenExtension.query.filter( + ForbiddenExtension.extension == 'bat').first()) + + def test_forbid_extension_rejects_dot_and_wildcards(self): + headers = self._admin('adm_ext') + for bad in ['.sh', '*', 'sh script']: + res = self._write('post', '/system/forbidden-extensions', + headers, {'extension': bad}) + self.assertEqual(res.status_code, 400, f'accepted {bad!r}') + + def test_forbid_extension_twice(self): + res = self._write('post', '/system/forbidden-extensions', + self._admin(), {'extension': 'exe'}) + self.assertEqual(res.status_code, 409) + + def test_allow_extension_again(self): + res = self.client.delete('/api/v1/system/forbidden-extensions/exe', + headers=self._admin()) + + self.assertEqual(res.status_code, 200) + self.assertIsNone(ForbiddenExtension.query.filter( + ForbiddenExtension.extension == 'exe').first()) + + def test_allow_extension_tolerates_leading_dot(self): + res = self.client.delete('/api/v1/system/forbidden-extensions/.exe', + headers=self._admin()) + self.assertEqual(res.status_code, 200) + + def test_allow_extension_not_forbidden(self): + res = self.client.delete('/api/v1/system/forbidden-extensions/mkv', + headers=self._admin()) + self.assertEqual(res.status_code, 404)