diff --git a/aurweb/routers/packages.py b/aurweb/routers/packages.py index 59b691ba..27a30205 100644 --- a/aurweb/routers/packages.py +++ b/aurweb/routers/packages.py @@ -13,7 +13,7 @@ from aurweb import db, defaults, l10n, logging, models, util from aurweb.auth import auth_required from aurweb.models.package_request import ACCEPTED_ID, PENDING_ID, REJECTED_ID from aurweb.models.relation_type import CONFLICTS_ID -from aurweb.models.request_type import DELETION_ID +from aurweb.models.request_type import DELETION_ID, MERGE, MERGE_ID from aurweb.packages.search import PackageSearch from aurweb.packages.util import get_pkg_or_base, get_pkgbase_comment, query_notified, query_voted from aurweb.scripts import notify, popupdate @@ -1053,6 +1053,7 @@ async def pkgbase_delete_post(request: Request, name: str, return render_template(request, "packages/delete.html", context, status_code=HTTPStatus.BAD_REQUEST) + # Obtain deletion locks and delete the packages. packages = pkgbase.packages.all() for package in packages: delete_package(request.user, package) @@ -1343,3 +1344,137 @@ async def pkgbase_merge_get(request: Request, name: str, return render_template(request, "pkgbase/merge.html", context, status_code=status_code) + + +def pkgbase_merge_instance(request: Request, pkgbase: models.PackageBase, + target: models.PackageBase): + pkgbasename = str(pkgbase.Name) + + # Collect requests related to this merge. + query = pkgbase.requests.filter( + models.PackageRequest.ReqTypeID == MERGE_ID + ) + + requests = query.filter( + models.PackageRequest.MergeBaseName == target.Name).all() + reject_requests = query.filter( + models.PackageRequest.MergeBaseName != target.Name).all() + + notifs = [] # Used to keep track of notifications over the function. + closure_comment = (f"Merged into package base {target.Name} by " + f"{request.user.Username}.") + rejected_closure_comment = ("Rejected because another merge request " + "for the same package base was accepted.") + conn = db.ConnectionExecutor(db.get_engine().raw_connection()) + if not requests: + # If there are no requests, create one owned by request.user. + with db.begin(): + pkgreq = db.create(models.PackageRequest, + ReqTypeID=MERGE_ID, + User=request.user, + PackageBase=pkgbase, + PackageBaseName=pkgbasename, + MergeBaseName=target.Name, + Comments="Generated by aurweb.", + Status=ACCEPTED_ID, + ClosureComment=closure_comment) + requests.append(pkgreq) + + # Add a notification about the opening to our notifs array. + notif = notify.RequestOpenNotification( + conn, request.user.ID, pkgreq.ID, MERGE, + pkgbase.ID, merge_into=target.Name) + notifs.append(notif) + + with db.begin(): + # Merge pkgbase's comments, notifications and votes into target. + for comment in pkgbase.comments: + comment.PackageBase = target + for notif in pkgbase.notifications: + notif.PackageBase = target + for vote in pkgbase.package_votes: + vote.PackageBase = target + + with db.begin(): + # Delete pkgbase and its packages now that everything's merged. + for pkg in pkgbase.packages: + db.session.delete(pkg) + db.session.delete(pkgbase) + + # Accept merge requests related to this pkgbase and target. + for pkgreq in requests: + pkgreq.Status = ACCEPTED_ID + pkgreq.ClosureComment = closure_comment + pkgreq.Closer = request.user + + for pkgreq in reject_requests: + pkgreq.Status = REJECTED_ID + pkgreq.ClosureComment = rejected_closure_comment + pkgreq.Closer = request.user + + all_requests = requests + reject_requests + for pkgreq in all_requests: + # Create notifications for request closure. + notif = notify.RequestCloseNotification( + conn, request.user.ID, pkgreq.ID, pkgreq.status_display()) + notifs.append(notif) + + conn.close() + + # Log this out for accountability purposes. + logger.info(f"Trusted User '{request.user.Username}' merged " + f"'{pkgbasename}' into '{target.Name}'.") + + # Send our notifications array. + util.apply_all(notifs, lambda n: n.send()) + + +@router.post("/pkgbase/{name}/merge") +@auth_required(redirect="/pkgbase/{name}/merge") +async def pkgbase_merge_post(request: Request, name: str, + into: str = Form(default=str()), + confirm: bool = Form(default=False), + next: str = Form(default=str())): + + pkgbase = get_pkg_or_base(name, models.PackageBase) + context = await make_variable_context(request, "Package Merging") + context["pkgbase"] = pkgbase + + # TODO: Lookup errors from credential instead of hardcoding them. + if not request.user.has_credential("CRED_PKGBASE_MERGE"): + context["errors"] = [ + "Only Trusted Users and Developers can merge packages."] + return render_template(request, "pkgbase/merge.html", context, + status_code=HTTPStatus.UNAUTHORIZED) + + if not confirm: + context["errors"] = ["The selected packages have not been deleted, " + "check the confirmation checkbox."] + return render_template(request, "pkgbase/merge.html", context, + status_code=HTTPStatus.BAD_REQUEST) + + try: + target = get_pkg_or_base(into, models.PackageBase) + except HTTPException: + context["errors"] = [ + "Cannot find package to merge votes and comments into."] + return render_template(request, "pkgbase/merge.html", context, + status_code=HTTPStatus.BAD_REQUEST) + + if pkgbase == target: + context["errors"] = ["Cannot merge a package base with itself."] + return render_template(request, "pkgbase/merge.html", context, + status_code=HTTPStatus.BAD_REQUEST) + + # Merge pkgbase into target. + pkgbase_merge_instance(request, pkgbase, target) + + # Run popupdate on the target. + conn = db.ConnectionExecutor(db.get_engine().raw_connection()) + popupdate.run_single(conn, target) + + if not next: + next = f"/pkgbase/{target.Name}" + + # Redirect to the newly merged into package. + return RedirectResponse(next, status_code=HTTPStatus.SEE_OTHER) diff --git a/templates/pkgbase/merge.html b/templates/pkgbase/merge.html index d0a50332..41f69916 100644 --- a/templates/pkgbase/merge.html +++ b/templates/pkgbase/merge.html @@ -1,6 +1,15 @@ {% extends "partials/layout.html" %} {% block pageContent %} + + {% if errors %} + + {% endif %} +

{{ "Merge Package" | tr }}: {{ pkgbase.Name }}

diff --git a/test/test_packages_routes.py b/test/test_packages_routes.py index f7f30330..7e67775a 100644 --- a/test/test_packages_routes.py +++ b/test/test_packages_routes.py @@ -8,6 +8,7 @@ from unittest import mock import pytest from fastapi.testclient import TestClient +from sqlalchemy import and_ from aurweb import asgi, db, defaults from aurweb.models.account_type import USER_ID, AccountType @@ -24,7 +25,7 @@ from aurweb.models.package_relation import PackageRelation from aurweb.models.package_request import ACCEPTED_ID, REJECTED_ID, PackageRequest from aurweb.models.package_vote import PackageVote from aurweb.models.relation_type import PROVIDES_ID, RelationType -from aurweb.models.request_type import DELETION_ID, RequestType +from aurweb.models.request_type import DELETION_ID, MERGE_ID, RequestType from aurweb.models.user import User from aurweb.testing import setup_test_db from aurweb.testing.html import get_errors, get_successes, parse_root @@ -2391,3 +2392,134 @@ def test_packages_post_delete(caplog: pytest.fixture, client: TestClient, expected = (f"Privileged user '{tu_user.Username}' deleted the " f"following packages: {str(packages)}.") assert expected in caplog.text + + +def test_pkgbase_merge_post_unauthorized(client: TestClient, user: User, + package: Package): + cookies = {"AURSID": user.login(Request(), "testPassword")} + endpoint = f"/pkgbase/{package.PackageBase.Name}/merge" + with client as request: + resp = request.post(endpoint, cookies=cookies) + assert resp.status_code == int(HTTPStatus.UNAUTHORIZED) + + +def test_pkgbase_merge_post_unconfirmed(client: TestClient, tu_user: User, + package: Package): + cookies = {"AURSID": tu_user.login(Request(), "testPassword")} + endpoint = f"/pkgbase/{package.PackageBase.Name}/merge" + with client as request: + resp = request.post(endpoint, cookies=cookies) + assert resp.status_code == int(HTTPStatus.BAD_REQUEST) + errors = get_errors(resp.text) + expected = ("The selected packages have not been deleted, " + "check the confirmation checkbox.") + assert errors[0].text.strip() == expected + + +def test_pkgbase_merge_post_invalid_into(client: TestClient, tu_user: User, + package: Package): + cookies = {"AURSID": tu_user.login(Request(), "testPassword")} + endpoint = f"/pkgbase/{package.PackageBase.Name}/merge" + with client as request: + resp = request.post(endpoint, data={ + "into": "not_real", + "confirm": True + }, cookies=cookies) + assert resp.status_code == int(HTTPStatus.BAD_REQUEST) + errors = get_errors(resp.text) + expected = "Cannot find package to merge votes and comments into." + assert errors[0].text.strip() == expected + + +def test_pkgbase_merge_post_self_invalid(client: TestClient, tu_user: User, + package: Package): + cookies = {"AURSID": tu_user.login(Request(), "testPassword")} + endpoint = f"/pkgbase/{package.PackageBase.Name}/merge" + with client as request: + resp = request.post(endpoint, data={ + "into": package.PackageBase.Name, + "confirm": True + }, cookies=cookies) + assert resp.status_code == int(HTTPStatus.BAD_REQUEST) + errors = get_errors(resp.text) + expected = "Cannot merge a package base with itself." + assert errors[0].text.strip() == expected + + +def test_pkgbase_merge_post(client: TestClient, tu_user: User, + packages: List[Package]): + package, target = packages[:2] + pkgname = package.Name + pkgbasename = package.PackageBase.Name + + # Create a merge request destined for another target. + # This will allow our test code to exercise closing + # such a request after merging the pkgbase in question. + with db.begin(): + pkgreq = db.create(PackageRequest, + User=tu_user, + ReqTypeID=MERGE_ID, + PackageBase=package.PackageBase, + PackageBaseName=pkgbasename, + MergeBaseName="test", + Comments="Test comment.", + ClosureComment="Test closure.") + + # Vote for the package. + cookies = {"AURSID": tu_user.login(Request(), "testPassword")} + endpoint = f"/pkgbase/{package.PackageBase.Name}/vote" + with client as request: + resp = request.post(endpoint, cookies=cookies) + assert resp.status_code == int(HTTPStatus.SEE_OTHER) + + # Enable notifications. + endpoint = f"/pkgbase/{package.PackageBase.Name}/notify" + with client as request: + resp = request.post(endpoint, cookies=cookies) + assert resp.status_code == int(HTTPStatus.SEE_OTHER) + + # Comment on the package. + endpoint = f"/pkgbase/{package.PackageBase.Name}/comments" + with client as request: + resp = request.post(endpoint, data={ + "comment": "Test comment." + }, cookies=cookies) + assert resp.status_code == int(HTTPStatus.SEE_OTHER) + + # Save these relationships for later comparison. + comments = package.PackageBase.comments.all() + notifs = package.PackageBase.notifications.all() + votes = package.PackageBase.package_votes.all() + + # Merge the package into target. + endpoint = f"/pkgbase/{package.PackageBase.Name}/merge" + with client as request: + resp = request.post(endpoint, data={ + "into": target.PackageBase.Name, + "confirm": True + }, cookies=cookies) + assert resp.status_code == int(HTTPStatus.SEE_OTHER) + loc = resp.headers.get("location") + assert loc == f"/pkgbase/{target.PackageBase.Name}" + + # Assert that the original comments, notifs and votes we setup + # got migrated to target as intended. + assert comments == target.PackageBase.comments.all() + assert notifs == target.PackageBase.notifications.all() + assert votes == target.PackageBase.package_votes.all() + + # ...and that the package got deleted. + package = db.query(Package).filter(Package.Name == pkgname).first() + assert package is None + + # Our fake target request should have gotten rejected. + assert pkgreq.Status == REJECTED_ID + assert pkgreq.Closer is not None + + # A PackageRequest is always created when merging this way. + pkgreq = db.query(PackageRequest).filter( + and_(PackageRequest.ReqTypeID == MERGE_ID, + PackageRequest.PackageBaseName == pkgbasename, + PackageRequest.MergeBaseName == target.PackageBase.Name) + ).first() + assert pkgreq is not None