Verified Commit 8657fd33 authored by Kevin Morris's avatar Kevin Morris
Browse files

feat: GET|POST /account/{name}/delete

Closes #348



Signed-off-by: Kevin Morris's avatarKevin Morris <kevr@0cost.org>
parent 1180565d
Pipeline #32031 passed with stages
in 2 minutes and 42 seconds
......@@ -14,7 +14,7 @@ class PackageVote(Base):
User = relationship(
_User,
backref=backref("package_votes", lazy="dynamic"),
backref=backref("package_votes", lazy="dynamic", cascade="all, delete"),
foreign_keys=[__table__.c.UsersID],
)
......
......@@ -13,7 +13,7 @@ class Session(Base):
User = relationship(
_User,
backref=backref("session", uselist=False),
backref=backref("session", cascade="all, delete", uselist=False),
foreign_keys=[__table__.c.UsersID],
)
......
......@@ -3,13 +3,13 @@ import typing
from http import HTTPStatus
from typing import Any
from fastapi import APIRouter, Form, Request
from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy import and_, or_
import aurweb.config
from aurweb import cookies, db, l10n, logging, models, util
from aurweb.auth import account_type_required, requires_auth, requires_guest
from aurweb.auth import account_type_required, creds, requires_auth, requires_guest
from aurweb.captcha import get_captcha_salts
from aurweb.exceptions import ValidationError, handle_form_exceptions
from aurweb.l10n import get_translator_for_request
......@@ -598,6 +598,78 @@ async def accounts_post(
return render_template(request, "account/index.html", context)
@router.get("/account/{name}/delete")
@requires_auth
async def account_delete(request: Request, name: str):
user = db.query(models.User).filter(models.User.Username == name).first()
if not user:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
has_cred = request.user.has_credential(creds.ACCOUNT_EDIT, approved=[user])
if not has_cred:
_ = l10n.get_translator_for_request(request)
raise HTTPException(
detail=_("You do not have permission to edit this account."),
status_code=HTTPStatus.UNAUTHORIZED,
)
context = make_context(request, "Accounts")
context["name"] = name
return render_template(request, "account/delete.html", context)
@db.async_retry_deadlock
@router.post("/account/{name}/delete")
@handle_form_exceptions
@requires_auth
async def account_delete_post(
request: Request,
name: str,
passwd: str = Form(default=str()),
confirm: bool = Form(default=False),
):
user = db.query(models.User).filter(models.User.Username == name).first()
if not user:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
has_cred = request.user.has_credential(creds.ACCOUNT_EDIT, approved=[user])
if not has_cred:
_ = l10n.get_translator_for_request(request)
raise HTTPException(
detail=_("You do not have permission to edit this account."),
status_code=HTTPStatus.UNAUTHORIZED,
)
context = make_context(request, "Accounts")
context["name"] = name
confirm = util.strtobool(confirm)
if not confirm:
context["errors"] = [
"The account has not been deleted, check the confirmation checkbox."
]
return render_template(
request,
"account/delete.html",
context,
status_code=HTTPStatus.BAD_REQUEST,
)
if not request.user.valid_password(passwd):
context["errors"] = ["Invalid password."]
return render_template(
request,
"account/delete.html",
context,
status_code=HTTPStatus.BAD_REQUEST,
)
with db.begin():
db.delete(user)
return RedirectResponse("/", status_code=HTTPStatus.SEE_OTHER)
def render_terms_of_service(request: Request, context: dict, terms: typing.Iterable):
if not terms:
return RedirectResponse("/", status_code=HTTPStatus.SEE_OTHER)
......
......@@ -2346,3 +2346,7 @@ msgstr ""
#: templates/partials/packages/package_metadata.html
msgid "dependencies"
msgstr ""
#: aurweb/routers/accounts.py
msgid "The account has not been deleted, check the confirmation checkbox."
msgstr ""
{% extends "partials/layout.html" %}
{% block pageContent %}
<div class="box">
<h2>{{ "Accounts" | tr }}</h2>
{% include "partials/error.html" %}
<p>
{{
"You can use this form to permanently delete the AUR account %s%s%s."
| tr | format("<strong>", name, "</strong>") | safe
}}
</p>
<p>
{{
"%sWARNING%s: This action cannot be undone."
| tr | format("<strong>", "</strong>") | safe
}}
</p>
<form id="edit-profile-form" action="{{ '/account/%s/delete' | format(name) }}" method="post">
<fieldset>
<p>
<label for="id_passwd">{{ "Password" | tr }}:</label>
<input id="id_passwd" type="password" size="30" name="passwd">
</p>
<p>
<label class="confirmation">
<input type="checkbox" name="confirm">
{{ "Confirm deletion" | tr }}
</label>
</p>
<p>
<button class="button" type="submit">{{ "Delete" | tr }}</button>
</p>
</fieldset>
</form>
</div>
{% endblock %}
......@@ -1949,3 +1949,106 @@ def test_accounts_unauthorized(client: TestClient, user: User):
resp = request.get("/accounts", cookies=cookies, allow_redirects=False)
assert resp.status_code == int(HTTPStatus.SEE_OTHER)
assert resp.headers.get("location") == "/"
def test_account_delete_self_unauthorized(client: TestClient, tu_user: User):
with db.begin():
user = create_user("some_user")
user2 = create_user("user2")
cookies = {"AURSID": user.login(Request(), "testPassword")}
endpoint = f"/account/{user2.Username}/delete"
with client as request:
resp = request.get(endpoint, cookies=cookies)
assert resp.status_code == HTTPStatus.UNAUTHORIZED
resp = request.post(endpoint, cookies=cookies)
assert resp.status_code == HTTPStatus.UNAUTHORIZED
# But a TU does have access
cookies = {"AURSID": tu_user.login(Request(), "testPassword")}
with TestClient(app=app) as request:
resp = request.get(endpoint, cookies=cookies)
assert resp.status_code == HTTPStatus.OK
def test_account_delete_self_not_found(client: TestClient, user: User):
cookies = {"AURSID": user.login(Request(), "testPassword")}
endpoint = "/account/non-existent-user/delete"
with client as request:
resp = request.get(endpoint, cookies=cookies)
assert resp.status_code == HTTPStatus.NOT_FOUND
resp = request.post(endpoint, cookies=cookies)
assert resp.status_code == HTTPStatus.NOT_FOUND
def test_account_delete_self(client: TestClient, user: User):
username = user.Username
# Confirm that we can view our own account deletion page
cookies = {"AURSID": user.login(Request(), "testPassword")}
endpoint = f"/account/{username}/delete"
with client as request:
resp = request.get(endpoint, cookies=cookies)
assert resp.status_code == HTTPStatus.OK
# The checkbox must be checked
with client as request:
resp = request.post(
endpoint,
data={"passwd": "fakePassword", "confirm": False},
cookies=cookies,
)
assert resp.status_code == HTTPStatus.BAD_REQUEST
errors = get_errors(resp.text)
assert (
errors[0].text.strip()
== "The account has not been deleted, check the confirmation checkbox."
)
# The correct password must be supplied
with client as request:
resp = request.post(
endpoint,
data={"passwd": "fakePassword", "confirm": True},
cookies=cookies,
)
assert resp.status_code == HTTPStatus.BAD_REQUEST
errors = get_errors(resp.text)
assert errors[0].text.strip() == "Invalid password."
# Supply everything correctly and delete ourselves
with client as request:
resp = request.post(
endpoint,
data={"passwd": "testPassword", "confirm": True},
cookies=cookies,
)
assert resp.status_code == HTTPStatus.SEE_OTHER
# Check that our User record no longer exists in the database
record = db.query(User).filter(User.Username == username).first()
assert record is None
def test_account_delete_as_tu(client: TestClient, tu_user: User):
with db.begin():
user = create_user("user2")
cookies = {"AURSID": tu_user.login(Request(), "testPassword")}
username = user.Username
endpoint = f"/account/{username}/delete"
# Delete the user
with client as request:
resp = request.post(
endpoint,
data={"passwd": "testPassword", "confirm": True},
cookies=cookies,
)
assert resp.status_code == HTTPStatus.SEE_OTHER
# Check that our User record no longer exists in the database
record = db.query(User).filter(User.Username == username).first()
assert record is None
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment