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

Closes #348

Signed-off-by: Kevin Morris <kevr@0cost.org>
This commit is contained in:
Kevin Morris 2022-09-29 17:43:26 -07:00
parent 1180565d0c
commit 8657fd336e
No known key found for this signature in database
GPG key ID: F7E46DED420788F3
6 changed files with 226 additions and 4 deletions

View file

@ -14,7 +14,7 @@ class PackageVote(Base):
User = relationship( User = relationship(
_User, _User,
backref=backref("package_votes", lazy="dynamic"), backref=backref("package_votes", lazy="dynamic", cascade="all, delete"),
foreign_keys=[__table__.c.UsersID], foreign_keys=[__table__.c.UsersID],
) )

View file

@ -13,7 +13,7 @@ class Session(Base):
User = relationship( User = relationship(
_User, _User,
backref=backref("session", uselist=False), backref=backref("session", cascade="all, delete", uselist=False),
foreign_keys=[__table__.c.UsersID], foreign_keys=[__table__.c.UsersID],
) )

View file

@ -3,13 +3,13 @@ import typing
from http import HTTPStatus from http import HTTPStatus
from typing import Any from typing import Any
from fastapi import APIRouter, Form, Request from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy import and_, or_ from sqlalchemy import and_, or_
import aurweb.config import aurweb.config
from aurweb import cookies, db, l10n, logging, models, util 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.captcha import get_captcha_salts
from aurweb.exceptions import ValidationError, handle_form_exceptions from aurweb.exceptions import ValidationError, handle_form_exceptions
from aurweb.l10n import get_translator_for_request from aurweb.l10n import get_translator_for_request
@ -598,6 +598,78 @@ async def accounts_post(
return render_template(request, "account/index.html", context) 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): def render_terms_of_service(request: Request, context: dict, terms: typing.Iterable):
if not terms: if not terms:
return RedirectResponse("/", status_code=HTTPStatus.SEE_OTHER) return RedirectResponse("/", status_code=HTTPStatus.SEE_OTHER)

View file

@ -2346,3 +2346,7 @@ msgstr ""
#: templates/partials/packages/package_metadata.html #: templates/partials/packages/package_metadata.html
msgid "dependencies" msgid "dependencies"
msgstr "" msgstr ""
#: aurweb/routers/accounts.py
msgid "The account has not been deleted, check the confirmation checkbox."
msgstr ""

View file

@ -0,0 +1,43 @@
{% 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 %}

View file

@ -1949,3 +1949,106 @@ def test_accounts_unauthorized(client: TestClient, user: User):
resp = request.get("/accounts", cookies=cookies, allow_redirects=False) resp = request.get("/accounts", cookies=cookies, allow_redirects=False)
assert resp.status_code == int(HTTPStatus.SEE_OTHER) assert resp.status_code == int(HTTPStatus.SEE_OTHER)
assert resp.headers.get("location") == "/" 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