mirror of
https://gitlab.archlinux.org/archlinux/aurweb.git
synced 2025-02-03 10:43:03 +01:00
add passreset routes
Introduced `get|post` `/passreset` routes. These routes mimic the behavior of the existing PHP implementation, with the exception of HTTP status code returns. Routes added: GET /passreset POST /passreset Routers added: aurweb.routers.accounts * On an unknown user or mismatched resetkey (where resetkey must == user.resetkey), return HTTP status NOT_FOUND (404). * On another error in the request, return HTTP status BAD_REQUEST (400). Both `get|post` routes requires that the current user is **not** authenticated, hence `@auth_required(False, redirect="/")`. + Added auth_required decorator to aurweb.auth. + Added some more utility to aurweb.models.user.User. + Added `partials/error.html` template. + Added `passreset.html` template. + Added aurweb.db.ConnectionExecutor functor for paramstyle logic. Decoupling the executor logic from the database connection logic is needed for us to easily use the same logic with a fastapi database session, when we need to use aurweb.scripts modules. At this point, notification configuration is now required to complete tests involved with notifications properly, like passreset. `conf/config.dev` has been modified to include [notifications] sendmail, sender and reply-to overrides. Dockerfile and .gitlab-ci.yml have been updated to setup /etc/hosts and start postfix before running tests. * setup.cfg: ignore E741, C901 in aurweb.routers.accounts These two warnings (shown in the commit) are not dangerous and a bi-product of maintaining compatibility with our current code flow. Signed-off-by: Kevin Morris <kevr@0cost.org>
This commit is contained in:
parent
4423326cec
commit
a33d076d8b
15 changed files with 552 additions and 41 deletions
|
@ -15,6 +15,8 @@ before_script:
|
|||
python-itsdangerous python-httpx python-jinja python-pytest-cov
|
||||
python-requests python-aiofiles python-python-multipart
|
||||
python-pytest-asyncio python-coverage python-bcrypt
|
||||
- bash -c "echo '127.0.0.1' > /etc/hosts"
|
||||
- bash -c "echo '::1' >> /etc/hosts"
|
||||
|
||||
test:
|
||||
script:
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import http
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
@ -11,7 +10,7 @@ import aurweb.config
|
|||
|
||||
from aurweb.auth import BasicAuthBackend
|
||||
from aurweb.db import get_engine
|
||||
from aurweb.routers import auth, html, sso, errors
|
||||
from aurweb.routers import accounts, auth, errors, html, sso
|
||||
|
||||
routes = set()
|
||||
|
||||
|
@ -43,6 +42,7 @@ async def app_startup():
|
|||
app.include_router(sso.router)
|
||||
app.include_router(html.router)
|
||||
app.include_router(auth.router)
|
||||
app.include_router(accounts.router)
|
||||
|
||||
# Initialize the database engine and ORM.
|
||||
get_engine()
|
||||
|
|
70
aurweb/db.py
70
aurweb/db.py
|
@ -145,35 +145,21 @@ def connect():
|
|||
return get_engine().connect()
|
||||
|
||||
|
||||
class Connection:
|
||||
class ConnectionExecutor:
|
||||
_conn = None
|
||||
_paramstyle = None
|
||||
|
||||
def __init__(self):
|
||||
aur_db_backend = aurweb.config.get('database', 'backend')
|
||||
|
||||
if aur_db_backend == 'mysql':
|
||||
def __init__(self, conn, backend=aurweb.config.get("database", "backend")):
|
||||
self._conn = conn
|
||||
if backend == "mysql":
|
||||
import mysql.connector
|
||||
aur_db_host = aurweb.config.get('database', 'host')
|
||||
aur_db_name = aurweb.config.get('database', 'name')
|
||||
aur_db_user = aurweb.config.get('database', 'user')
|
||||
aur_db_pass = aurweb.config.get('database', 'password')
|
||||
aur_db_socket = aurweb.config.get('database', 'socket')
|
||||
self._conn = mysql.connector.connect(host=aur_db_host,
|
||||
user=aur_db_user,
|
||||
passwd=aur_db_pass,
|
||||
db=aur_db_name,
|
||||
unix_socket=aur_db_socket,
|
||||
buffered=True)
|
||||
self._paramstyle = mysql.connector.paramstyle
|
||||
elif aur_db_backend == 'sqlite':
|
||||
elif backend == "sqlite":
|
||||
import sqlite3
|
||||
aur_db_name = aurweb.config.get('database', 'name')
|
||||
self._conn = sqlite3.connect(aur_db_name)
|
||||
self._conn.create_function("POWER", 2, math.pow)
|
||||
self._paramstyle = sqlite3.paramstyle
|
||||
else:
|
||||
raise ValueError('unsupported database backend')
|
||||
|
||||
def paramstyle(self):
|
||||
return self._paramstyle
|
||||
|
||||
def execute(self, query, params=()):
|
||||
if self._paramstyle in ('format', 'pyformat'):
|
||||
|
@ -193,3 +179,43 @@ class Connection:
|
|||
|
||||
def close(self):
|
||||
self._conn.close()
|
||||
|
||||
|
||||
class Connection:
|
||||
_executor = None
|
||||
_conn = None
|
||||
|
||||
def __init__(self):
|
||||
aur_db_backend = aurweb.config.get('database', 'backend')
|
||||
|
||||
if aur_db_backend == 'mysql':
|
||||
import mysql.connector
|
||||
aur_db_host = aurweb.config.get('database', 'host')
|
||||
aur_db_name = aurweb.config.get('database', 'name')
|
||||
aur_db_user = aurweb.config.get('database', 'user')
|
||||
aur_db_pass = aurweb.config.get('database', 'password')
|
||||
aur_db_socket = aurweb.config.get('database', 'socket')
|
||||
self._conn = mysql.connector.connect(host=aur_db_host,
|
||||
user=aur_db_user,
|
||||
passwd=aur_db_pass,
|
||||
db=aur_db_name,
|
||||
unix_socket=aur_db_socket,
|
||||
buffered=True)
|
||||
elif aur_db_backend == 'sqlite':
|
||||
import sqlite3
|
||||
aur_db_name = aurweb.config.get('database', 'name')
|
||||
self._conn = sqlite3.connect(aur_db_name)
|
||||
self._conn.create_function("POWER", 2, math.pow)
|
||||
else:
|
||||
raise ValueError('unsupported database backend')
|
||||
|
||||
self._conn = ConnectionExecutor(self._conn)
|
||||
|
||||
def execute(self, query, params=()):
|
||||
return self._conn.execute(query, params)
|
||||
|
||||
def commit(self):
|
||||
self._conn.commit()
|
||||
|
||||
def close(self):
|
||||
self._conn.close()
|
||||
|
|
102
aurweb/routers/accounts.py
Normal file
102
aurweb/routers/accounts.py
Normal file
|
@ -0,0 +1,102 @@
|
|||
from http import HTTPStatus
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from sqlalchemy import or_
|
||||
|
||||
from aurweb import db
|
||||
from aurweb.auth import auth_required
|
||||
from aurweb.l10n import get_translator_for_request
|
||||
from aurweb.models.user import User
|
||||
from aurweb.scripts.notify import ResetKeyNotification
|
||||
from aurweb.templates import make_context, render_template
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/passreset", response_class=HTMLResponse)
|
||||
@auth_required(False)
|
||||
async def passreset(request: Request):
|
||||
context = make_context(request, "Password Reset")
|
||||
|
||||
for k, v in request.query_params.items():
|
||||
context[k] = v
|
||||
|
||||
return render_template(request, "passreset.html", context)
|
||||
|
||||
|
||||
@router.post("/passreset", response_class=HTMLResponse)
|
||||
@auth_required(False)
|
||||
async def passreset_post(request: Request,
|
||||
user: str = Form(...),
|
||||
resetkey: str = Form(default=None),
|
||||
password: str = Form(default=None),
|
||||
confirm: str = Form(default=None)):
|
||||
from aurweb.db import session
|
||||
|
||||
context = make_context(request, "Password Reset")
|
||||
|
||||
for k, v in dict(await request.form()).items():
|
||||
context[k] = v
|
||||
|
||||
# The user parameter being required, we can match against
|
||||
user = db.query(User, or_(User.Username == user,
|
||||
User.Email == user)).first()
|
||||
if not user:
|
||||
context["errors"] = ["Invalid e-mail."]
|
||||
return render_template(request, "passreset.html", context,
|
||||
status_code=int(HTTPStatus.NOT_FOUND))
|
||||
|
||||
if resetkey:
|
||||
context["resetkey"] = resetkey
|
||||
|
||||
if not user.ResetKey or resetkey != user.ResetKey:
|
||||
context["errors"] = ["Invalid e-mail."]
|
||||
return render_template(request, "passreset.html", context,
|
||||
status_code=int(HTTPStatus.NOT_FOUND))
|
||||
|
||||
if not user or not password:
|
||||
context["errors"] = ["Missing a required field."]
|
||||
return render_template(request, "passreset.html", context,
|
||||
status_code=int(HTTPStatus.BAD_REQUEST))
|
||||
|
||||
if password != confirm:
|
||||
# If the provided password does not match the provided confirm.
|
||||
context["errors"] = ["Password fields do not match."]
|
||||
return render_template(request, "passreset.html", context,
|
||||
status_code=int(HTTPStatus.BAD_REQUEST))
|
||||
|
||||
if len(password) < User.minimum_passwd_length():
|
||||
# Translate the error here, which simplifies error output
|
||||
# in the jinja2 template.
|
||||
_ = get_translator_for_request(request)
|
||||
context["errors"] = [_(
|
||||
"Your password must be at least %s characters.") % (
|
||||
str(User.minimum_passwd_length()))]
|
||||
return render_template(request, "passreset.html", context,
|
||||
status_code=int(HTTPStatus.BAD_REQUEST))
|
||||
|
||||
# We got to this point; everything matched up. Update the password
|
||||
# and remove the ResetKey.
|
||||
user.ResetKey = str()
|
||||
user.update_password(password)
|
||||
|
||||
if user.session:
|
||||
session.delete(user.session)
|
||||
session.commit()
|
||||
|
||||
# Render ?step=complete.
|
||||
return RedirectResponse(url="/passreset?step=complete",
|
||||
status_code=int(HTTPStatus.SEE_OTHER))
|
||||
|
||||
# If we got here, we continue with issuing a resetkey for the user.
|
||||
resetkey = db.make_random_value(User, User.ResetKey)
|
||||
user.ResetKey = resetkey
|
||||
session.commit()
|
||||
|
||||
executor = db.ConnectionExecutor(db.get_engine().raw_connection())
|
||||
ResetKeyNotification(executor, user.ID).send()
|
||||
|
||||
# Render ?step=confirm.
|
||||
return RedirectResponse(url="/passreset?step=confirm",
|
||||
status_code=int(HTTPStatus.SEE_OTHER))
|
|
@ -6,6 +6,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
|||
|
||||
import aurweb.config
|
||||
|
||||
from aurweb.auth import auth_required
|
||||
from aurweb.models.user import User
|
||||
from aurweb.templates import make_context, render_template
|
||||
|
||||
|
@ -21,12 +22,13 @@ def login_template(request: Request, next: str, errors: list = None):
|
|||
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
@auth_required(False)
|
||||
async def login_get(request: Request, next: str = "/"):
|
||||
""" Homepage route. """
|
||||
return login_template(request, next)
|
||||
|
||||
|
||||
@router.post("/login", response_class=HTMLResponse)
|
||||
@auth_required(False)
|
||||
async def login_post(request: Request,
|
||||
next: str = Form(...),
|
||||
user: str = Form(default=str()),
|
||||
|
@ -45,8 +47,8 @@ async def login_post(request: Request,
|
|||
cookie_timeout = aurweb.config.getint(
|
||||
"options", "persistent_cookie_timeout")
|
||||
|
||||
_, sid = user.login(request, passwd, cookie_timeout)
|
||||
if not _:
|
||||
sid = user.login(request, passwd, cookie_timeout)
|
||||
if not sid:
|
||||
return login_template(request, next,
|
||||
errors=["Bad username or password."])
|
||||
|
||||
|
@ -62,6 +64,7 @@ async def login_post(request: Request,
|
|||
|
||||
|
||||
@router.get("/logout")
|
||||
@auth_required()
|
||||
async def logout(request: Request, next: str = "/"):
|
||||
""" A GET and POST route for logging out.
|
||||
|
||||
|
@ -81,5 +84,6 @@ async def logout(request: Request, next: str = "/"):
|
|||
|
||||
|
||||
@router.post("/logout")
|
||||
@auth_required()
|
||||
async def logout_post(request: Request, next: str = "/"):
|
||||
return await logout(request=request, next=next)
|
||||
|
|
|
@ -25,6 +25,12 @@ disable_http_login = 0
|
|||
enable-maintenance = 0
|
||||
localedir = YOUR_AUR_ROOT/web/locale
|
||||
|
||||
[notifications]
|
||||
; For development/testing, use /usr/bin/sendmail
|
||||
sendmail = YOUR_AUR_ROOT/util/sendmail
|
||||
sender = notify@localhost
|
||||
reply-to = noreply@localhost
|
||||
|
||||
; Single sign-on; see doc/sso.txt.
|
||||
[sso]
|
||||
openid_configuration = http://127.0.0.1:8083/auth/realms/aurweb/.well-known/openid-configuration
|
||||
|
|
13
setup.cfg
13
setup.cfg
|
@ -2,6 +2,19 @@
|
|||
max-line-length = 127
|
||||
max-complexity = 10
|
||||
|
||||
# Ignore some unavoidable flake8 warnings; we know this is against
|
||||
# pycodestyle, but some of the existing codebase uses `I` variables,
|
||||
# so specifically silence warnings about it in pre-defined files.
|
||||
# In E741, the 'I', 'O', 'l' are ambiguous variable names.
|
||||
# Our current implementation uses these variables through HTTP
|
||||
# and the FastAPI form specification wants them named as such.
|
||||
# In C901's case, our process_account_form function is way too
|
||||
# complex for PEP (too many if statements). However, we need to
|
||||
# process these anyways, and making it any more complex would
|
||||
# just add confusion to the implementation.
|
||||
per-file-ignores =
|
||||
aurweb/routers/accounts.py:E741,C901
|
||||
|
||||
[isort]
|
||||
line_length = 127
|
||||
lines_between_types = 1
|
||||
|
|
15
templates/partials/error.html
Normal file
15
templates/partials/error.html
Normal file
|
@ -0,0 +1,15 @@
|
|||
{% if errors %}
|
||||
<ul class="errorlist">
|
||||
{% for error in errors %}
|
||||
{% if error is string %}
|
||||
<li>{{ error | tr | safe }}</li>
|
||||
{% elif error is iterable %}
|
||||
<ul>
|
||||
{% for e in error %}
|
||||
<li>{{ e | tr | safe }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
76
templates/passreset.html
Normal file
76
templates/passreset.html
Normal file
|
@ -0,0 +1,76 @@
|
|||
{% extends "partials/layout.html" %}
|
||||
|
||||
{% block pageContent %}
|
||||
<div class="box">
|
||||
<h2>{% trans %}Password Reset{% endtrans %}</h2>
|
||||
|
||||
<p>
|
||||
{% if step == "confirm" %}
|
||||
{% trans %}Check your e-mail for the confirmation link.{% endtrans %}
|
||||
{% elif step == "complete" %}
|
||||
{% trans %}Your password has been reset successfully.{% endtrans %}
|
||||
{% elif resetkey %}
|
||||
<!-- Provided with a resetkey. -->
|
||||
{% include "partials/error.html" %}
|
||||
|
||||
<form method="post">
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{% trans %}Confirm your user name or primary e-mail address:{% endtrans %}</td>
|
||||
<td>
|
||||
<input type="text" name="user" size="30" maxlength="64"
|
||||
value="{{ user or '' }}">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{% trans %}Enter your new password:{% endtrans %}</td>
|
||||
<td>
|
||||
<input type="password" name="password" size="30"
|
||||
value="{{ password or '' }}">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{% trans %}Confirm your new password:{% endtrans %}</td>
|
||||
<td>
|
||||
<input type="password" name="confirm" size="30"
|
||||
value="{{ confirm or '' }}">
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<br>
|
||||
<input type="hidden" name="resetkey"
|
||||
value="{{ resetkey }}">
|
||||
<input class="button" type="submit"
|
||||
value="{% trans %}Continue{% endtrans %}">
|
||||
</form>
|
||||
{% else %}
|
||||
<!-- Default page with prompt for user name/e-mail. -->
|
||||
{% set url = "https://mailman.archlinux.org/mailman/listinfo/aur-general" %}
|
||||
{{ "If you have forgotten the user name and the primary e-mail "
|
||||
"address you used to register, please send a message to the "
|
||||
"%saur-general%s mailing list."
|
||||
| tr
|
||||
| format(
|
||||
'<a href="%s">' | format(url),
|
||||
"</a>")
|
||||
| safe
|
||||
}}
|
||||
</p>
|
||||
|
||||
{% include "partials/error.html" %}
|
||||
|
||||
<form method="post">
|
||||
<p>
|
||||
{% trans %}Enter your user name or your primary e-mail address:{% endtrans %}
|
||||
<input type="text" name="user" size="30" maxlength="64"
|
||||
value="{{ user or '' }}">
|
||||
</p>
|
||||
<input class="button" type="submit"
|
||||
value="{% trans %}Continue{% endtrans %}">
|
||||
</form>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -27,6 +27,7 @@ For all the test to run, the following Arch packages should be installed:
|
|||
- python-pytest
|
||||
- python-pytest-cov
|
||||
- python-pytest-asyncio
|
||||
- postfix
|
||||
|
||||
Running tests
|
||||
-------------
|
||||
|
@ -37,6 +38,10 @@ First, setup the test configuration:
|
|||
|
||||
$ sed -r 's;YOUR_AUR_ROOT;$(pwd);g' conf/config.dev > conf/config
|
||||
|
||||
You'll need to make sure that emails can be sent out by aurweb.scripts.notify.
|
||||
If you don't have anything setup, just install postfix and start it before
|
||||
running tests.
|
||||
|
||||
With those installed, one can run Python tests manually with any AUR config
|
||||
specified by `AUR_CONFIG`:
|
||||
|
||||
|
|
218
test/test_accounts_routes.py
Normal file
218
test/test_accounts_routes.py
Normal file
|
@ -0,0 +1,218 @@
|
|||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from aurweb.asgi import app
|
||||
from aurweb.db import query
|
||||
from aurweb.models.account_type import AccountType
|
||||
from aurweb.models.session import Session
|
||||
from aurweb.models.user import User
|
||||
from aurweb.testing import setup_test_db
|
||||
from aurweb.testing.models import make_user
|
||||
from aurweb.testing.requests import Request
|
||||
|
||||
# Some test global constants.
|
||||
TEST_USERNAME = "test"
|
||||
TEST_EMAIL = "test@example.org"
|
||||
|
||||
# Global mutables.
|
||||
client = TestClient(app)
|
||||
user = None
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup():
|
||||
global user
|
||||
|
||||
setup_test_db("Users", "Sessions", "Bans")
|
||||
|
||||
account_type = query(AccountType,
|
||||
AccountType.AccountType == "User").first()
|
||||
user = make_user(Username=TEST_USERNAME, Email=TEST_EMAIL,
|
||||
RealName="Test User", Passwd="testPassword",
|
||||
AccountType=account_type)
|
||||
|
||||
|
||||
def test_get_passreset_authed_redirects():
|
||||
sid = user.login(Request(), "testPassword")
|
||||
assert sid is not None
|
||||
|
||||
with client as request:
|
||||
response = request.get("/passreset", cookies={"AURSID": sid},
|
||||
allow_redirects=False)
|
||||
|
||||
assert response.status_code == int(HTTPStatus.SEE_OTHER)
|
||||
assert response.headers.get("location") == "/"
|
||||
|
||||
|
||||
def test_get_passreset():
|
||||
with client as request:
|
||||
response = request.get("/passreset")
|
||||
assert response.status_code == int(HTTPStatus.OK)
|
||||
|
||||
|
||||
def test_get_passreset_translation():
|
||||
# Test that translation works.
|
||||
with client as request:
|
||||
response = request.get("/passreset", cookies={"AURLANG": "de"})
|
||||
|
||||
# The header title should be translated.
|
||||
assert "Passwort zurücksetzen".encode("utf-8") in response.content
|
||||
|
||||
# The form input label should be translated.
|
||||
assert "Benutzername oder primäre E-Mail-Adresse eingeben:".encode(
|
||||
"utf-8") in response.content
|
||||
|
||||
# And the button.
|
||||
assert "Weiter".encode("utf-8") in response.content
|
||||
|
||||
|
||||
def test_get_passreset_with_resetkey():
|
||||
with client as request:
|
||||
response = request.get("/passreset", data={"resetkey": "abcd"})
|
||||
assert response.status_code == int(HTTPStatus.OK)
|
||||
|
||||
|
||||
def test_post_passreset_authed_redirects():
|
||||
sid = user.login(Request(), "testPassword")
|
||||
assert sid is not None
|
||||
|
||||
with client as request:
|
||||
response = request.post("/passreset",
|
||||
cookies={"AURSID": sid},
|
||||
data={"user": "blah"},
|
||||
allow_redirects=False)
|
||||
|
||||
assert response.status_code == int(HTTPStatus.SEE_OTHER)
|
||||
assert response.headers.get("location") == "/"
|
||||
|
||||
|
||||
def test_post_passreset_user():
|
||||
# With username.
|
||||
with client as request:
|
||||
response = request.post("/passreset", data={"user": TEST_USERNAME})
|
||||
assert response.status_code == int(HTTPStatus.SEE_OTHER)
|
||||
assert response.headers.get("location") == "/passreset?step=confirm"
|
||||
|
||||
# With e-mail.
|
||||
with client as request:
|
||||
response = request.post("/passreset", data={"user": TEST_EMAIL})
|
||||
assert response.status_code == int(HTTPStatus.SEE_OTHER)
|
||||
assert response.headers.get("location") == "/passreset?step=confirm"
|
||||
|
||||
|
||||
def test_post_passreset_resetkey():
|
||||
from aurweb.db import session
|
||||
|
||||
user.session = Session(UsersID=user.ID, SessionID="blah",
|
||||
LastUpdateTS=datetime.utcnow().timestamp())
|
||||
session.commit()
|
||||
|
||||
# Prepare a password reset.
|
||||
with client as request:
|
||||
response = request.post("/passreset", data={"user": TEST_USERNAME})
|
||||
assert response.status_code == int(HTTPStatus.SEE_OTHER)
|
||||
assert response.headers.get("location") == "/passreset?step=confirm"
|
||||
|
||||
# Now that we've prepared the password reset, prepare a POST
|
||||
# request with the user's ResetKey.
|
||||
resetkey = user.ResetKey
|
||||
post_data = {
|
||||
"user": TEST_USERNAME,
|
||||
"resetkey": resetkey,
|
||||
"password": "abcd1234",
|
||||
"confirm": "abcd1234"
|
||||
}
|
||||
|
||||
with client as request:
|
||||
response = request.post("/passreset", data=post_data)
|
||||
assert response.status_code == int(HTTPStatus.SEE_OTHER)
|
||||
assert response.headers.get("location") == "/passreset?step=complete"
|
||||
|
||||
|
||||
def test_post_passreset_error_invalid_email():
|
||||
# First, test with a user that doesn't even exist.
|
||||
with client as request:
|
||||
response = request.post("/passreset", data={"user": "invalid"})
|
||||
assert response.status_code == int(HTTPStatus.NOT_FOUND)
|
||||
|
||||
error = "Invalid e-mail."
|
||||
assert error in response.content.decode("utf-8")
|
||||
|
||||
# Then, test with an invalid resetkey for a real user.
|
||||
_ = make_resetkey()
|
||||
post_data = make_passreset_data("fake")
|
||||
post_data["password"] = "abcd1234"
|
||||
post_data["confirm"] = "abcd1234"
|
||||
|
||||
with client as request:
|
||||
response = request.post("/passreset", data=post_data)
|
||||
assert response.status_code == int(HTTPStatus.NOT_FOUND)
|
||||
assert error in response.content.decode("utf-8")
|
||||
|
||||
|
||||
def make_resetkey():
|
||||
with client as request:
|
||||
response = request.post("/passreset", data={"user": TEST_USERNAME})
|
||||
assert response.status_code == int(HTTPStatus.SEE_OTHER)
|
||||
assert response.headers.get("location") == "/passreset?step=confirm"
|
||||
return user.ResetKey
|
||||
|
||||
|
||||
def make_passreset_data(resetkey):
|
||||
return {
|
||||
"user": user.Username,
|
||||
"resetkey": resetkey
|
||||
}
|
||||
|
||||
|
||||
def test_post_passreset_error_missing_field():
|
||||
# Now that we've prepared the password reset, prepare a POST
|
||||
# request with the user's ResetKey.
|
||||
resetkey = make_resetkey()
|
||||
post_data = make_passreset_data(resetkey)
|
||||
|
||||
with client as request:
|
||||
response = request.post("/passreset", data=post_data)
|
||||
|
||||
assert response.status_code == int(HTTPStatus.BAD_REQUEST)
|
||||
|
||||
error = "Missing a required field."
|
||||
assert error in response.content.decode("utf-8")
|
||||
|
||||
|
||||
def test_post_passreset_error_password_mismatch():
|
||||
resetkey = make_resetkey()
|
||||
post_data = make_passreset_data(resetkey)
|
||||
|
||||
post_data["password"] = "abcd1234"
|
||||
post_data["confirm"] = "mismatched"
|
||||
|
||||
with client as request:
|
||||
response = request.post("/passreset", data=post_data)
|
||||
|
||||
assert response.status_code == int(HTTPStatus.BAD_REQUEST)
|
||||
|
||||
error = "Password fields do not match."
|
||||
assert error in response.content.decode("utf-8")
|
||||
|
||||
|
||||
def test_post_passreset_error_password_requirements():
|
||||
resetkey = make_resetkey()
|
||||
post_data = make_passreset_data(resetkey)
|
||||
|
||||
passwd_min_len = User.minimum_passwd_length()
|
||||
assert passwd_min_len >= 4
|
||||
|
||||
post_data["password"] = "x"
|
||||
post_data["confirm"] = "x"
|
||||
|
||||
with client as request:
|
||||
response = request.post("/passreset", data=post_data)
|
||||
|
||||
assert response.status_code == int(HTTPStatus.BAD_REQUEST)
|
||||
|
||||
error = f"Your password must be at least {passwd_min_len} characters."
|
||||
assert error in response.content.decode("utf-8")
|
|
@ -14,8 +14,12 @@ from aurweb.models.session import Session
|
|||
from aurweb.testing import setup_test_db
|
||||
from aurweb.testing.models import make_user
|
||||
|
||||
client = TestClient(app)
|
||||
# Some test global constants.
|
||||
TEST_USERNAME = "test"
|
||||
TEST_EMAIL = "test@example.org"
|
||||
|
||||
# Global mutables.
|
||||
client = TestClient(app)
|
||||
user = None
|
||||
|
||||
|
||||
|
@ -27,7 +31,8 @@ def setup():
|
|||
|
||||
account_type = query(AccountType,
|
||||
AccountType.AccountType == "User").first()
|
||||
user = make_user(Username="test", Email="test@example.org",
|
||||
|
||||
user = make_user(Username=TEST_USERNAME, Email=TEST_EMAIL,
|
||||
RealName="Test User", Passwd="testPassword",
|
||||
AccountType=account_type)
|
||||
|
||||
|
@ -40,16 +45,16 @@ def test_login_logout():
|
|||
}
|
||||
|
||||
with client as request:
|
||||
response = client.get("/login")
|
||||
# First, let's test get /login.
|
||||
response = request.get("/login")
|
||||
assert response.status_code == int(HTTPStatus.OK)
|
||||
|
||||
response = request.post("/login", data=post_data,
|
||||
allow_redirects=False)
|
||||
assert response.status_code == int(HTTPStatus.SEE_OTHER)
|
||||
|
||||
response = request.get(response.headers.get("location"), cookies={
|
||||
"AURSID": response.cookies.get("AURSID")
|
||||
})
|
||||
# Simulate following the redirect location from above's response.
|
||||
response = request.get(response.headers.get("location"))
|
||||
assert response.status_code == int(HTTPStatus.OK)
|
||||
|
||||
response = request.post("/logout", data=post_data,
|
||||
|
@ -60,9 +65,37 @@ def test_login_logout():
|
|||
"AURSID": response.cookies.get("AURSID")
|
||||
}, allow_redirects=False)
|
||||
assert response.status_code == int(HTTPStatus.SEE_OTHER)
|
||||
|
||||
assert "AURSID" not in response.cookies
|
||||
|
||||
|
||||
def test_authenticated_login_forbidden():
|
||||
post_data = {
|
||||
"user": "test",
|
||||
"passwd": "testPassword",
|
||||
"next": "/"
|
||||
}
|
||||
|
||||
with client as request:
|
||||
# Login.
|
||||
response = request.post("/login", data=post_data,
|
||||
allow_redirects=False)
|
||||
assert response.status_code == int(HTTPStatus.SEE_OTHER)
|
||||
|
||||
# Now, let's verify that we receive 403 Forbidden when we
|
||||
# try to get /login as an authenticated user.
|
||||
response = request.get("/login", allow_redirects=False)
|
||||
assert response.status_code == int(HTTPStatus.SEE_OTHER)
|
||||
|
||||
|
||||
def test_unauthenticated_logout_unauthorized():
|
||||
with client as request:
|
||||
# Alright, let's verify that attempting to /logout when not
|
||||
# authenticated returns 401 Unauthorized.
|
||||
response = request.get("/logout", allow_redirects=False)
|
||||
assert response.status_code == int(HTTPStatus.SEE_OTHER)
|
||||
|
||||
|
||||
def test_login_missing_username():
|
||||
post_data = {
|
||||
"passwd": "testPassword",
|
||||
|
@ -75,8 +108,6 @@ def test_login_missing_username():
|
|||
|
||||
|
||||
def test_login_remember_me():
|
||||
from aurweb.db import session
|
||||
|
||||
post_data = {
|
||||
"user": "test",
|
||||
"passwd": "testPassword",
|
||||
|
@ -94,7 +125,7 @@ def test_login_remember_me():
|
|||
"options", "persistent_cookie_timeout")
|
||||
expected_ts = datetime.utcnow().timestamp() + cookie_timeout
|
||||
|
||||
_session = session.query(Session).filter(
|
||||
_session = query(Session,
|
||||
Session.UsersID == user.ID).first()
|
||||
|
||||
# Expect that LastUpdateTS was within 5 seconds of the expected_ts,
|
||||
|
|
|
@ -3,7 +3,6 @@ import re
|
|||
import sqlite3
|
||||
import tempfile
|
||||
|
||||
from datetime import datetime
|
||||
from unittest import mock
|
||||
|
||||
import mysql.connector
|
||||
|
@ -185,3 +184,15 @@ def test_create_delete():
|
|||
db.delete(AccountType, AccountType.AccountType == "test")
|
||||
record = db.query(AccountType, AccountType.AccountType == "test").first()
|
||||
assert record is None
|
||||
|
||||
|
||||
@mock.patch("mysql.connector.paramstyle", "qmark")
|
||||
def test_connection_executor_mysql_paramstyle():
|
||||
executor = db.ConnectionExecutor(None, backend="mysql")
|
||||
assert executor.paramstyle() == "qmark"
|
||||
|
||||
|
||||
@mock.patch("sqlite3.paramstyle", "pyformat")
|
||||
def test_connection_executor_sqlite_paramstyle():
|
||||
executor = db.ConnectionExecutor(None, backend="sqlite")
|
||||
assert executor.paramstyle() == "pyformat"
|
||||
|
|
|
@ -28,7 +28,7 @@ def setup():
|
|||
|
||||
setup_test_db("Users", "Sessions", "Bans")
|
||||
|
||||
account_type = session.query(AccountType).filter(
|
||||
account_type = query(AccountType,
|
||||
AccountType.AccountType == "User").first()
|
||||
|
||||
user = make_user(Username="test", Email="test@example.org",
|
||||
|
@ -67,7 +67,7 @@ def test_user_login_logout():
|
|||
assert user.session.User == user
|
||||
|
||||
# Search for the user via query API.
|
||||
result = session.query(User).filter(User.ID == user.ID).first()
|
||||
result = query(User, User.ID == user.ID).first()
|
||||
|
||||
# Compare the result and our original user.
|
||||
assert result == user
|
||||
|
|
2
util/sendmail
Executable file
2
util/sendmail
Executable file
|
@ -0,0 +1,2 @@
|
|||
#!/bin/bash
|
||||
exit 0
|
Loading…
Add table
Reference in a new issue