Files
LLeMbas/tests/test_auth.py
T
Jaroslav Beneš 29db54960e Fix bulk actions, create chats lazily, rework the UI
Seven reported problems.

**Bulk model actions 404'd.** /admin/models/{model_id} was registered
before /admin/models/bulk, and FastAPI matches in registration order, so
"bulk" was parsed as a model id. Moved above the parameterised route,
with a comment saying why, and a regression test.

**Empty chats piled up.** There is now no endpoint that creates one.
"New chat" is a link to /chat, which renders a composer with no row
behind it, and POST /api/chats/start writes the chat together with its
first message. Opening one and walking away leaves nothing.

**Pinning meant two different things.** The picker is now always in the
administrator's position order; pinned models get shortcuts in the chat
sidebar and nothing else. A picker whose order silently differs from the
admin screen is just confusing.

**Model images were missing in chat.** Assistant bubbles now show the
avatar of the model that actually wrote the turn -- which is not always
the model the chat is set to now -- falling back to the LLeMbas mark.
The picker shows it too.

**No global or per-model system prompt.** Three layers now: instance
(Admin -> General), model (Admin -> Models), chat. Precedence, not
concatenation: most specific wins outright. Stacking them reads well in
a settings screen and badly in practice, because two layers that
disagree give the model contradictory instructions and nobody can tell
which is losing. The chat panel shows the inherited prompt as
placeholder text so "leave empty to inherit" is not a guess.

**Alignment and button sizing.** Added --control-h and friends to
tokens.css; every button, input and select takes its height from them,
so a mixed row is flush by construction rather than by per-instance
nudging. Icon buttons are square at that height. Added .btn-row,
.card__header/.card__footer and .grid so pages stop carrying inline
styles, and moved every admin page onto them.

**Settings needed structure.** The user settings page is now tabbed
(Account / Models / Appearance / Security) using radio inputs and
sibling selectors -- no JavaScript, and the browser keeps the chosen tab
across a re-render.

Caught while checking: the chat.css surgery had deleted the attachment,
chip and dropzone rules. Restored, and there is now a check that every
literal class used in a template has a CSS rule.

197 tests, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 12:59:52 +02:00

135 lines
4.7 KiB
Python

"""Registration, sign-in, and the route guards."""
from __future__ import annotations
from fastapi.testclient import TestClient
from sqlalchemy import select
from lembas.db.models import ROLE_ADMIN, ROLE_USER, User
def test_empty_instance_sends_you_to_registration(client: TestClient):
response = client.get("/auth/login", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/auth/register"
def test_first_account_becomes_admin(client: TestClient, db, registered):
user = db.scalar(select(User).where(User.email == registered["email"]))
assert user.role == ROLE_ADMIN
def test_second_account_is_an_ordinary_user(client: TestClient, db, registered):
client.post("/auth/logout", follow_redirects=False)
client.post(
"/auth/register",
data={"name": "Sam", "email": "sam@shire.test", "password": "potatoes-po-ta-toes"},
follow_redirects=False,
)
user = db.scalar(select(User).where(User.email == "sam@shire.test"))
assert user.role == ROLE_USER
def test_registration_signs_you_in(client: TestClient, registered):
assert client.get("/chat").status_code == 200
def test_duplicate_email_is_rejected(client: TestClient, registered):
client.post("/auth/logout", follow_redirects=False)
response = client.post("/auth/register", data=registered, follow_redirects=False)
assert response.status_code == 400
assert "already exists" in response.text
def test_short_password_is_rejected(client: TestClient):
response = client.post(
"/auth/register",
data={"name": "A", "email": "a@b.test", "password": "short"},
follow_redirects=False,
)
assert response.status_code == 400
assert "8 characters" in response.text
def test_sign_in_and_out(client: TestClient, registered):
client.post("/auth/logout", follow_redirects=False)
assert client.get("/chat", follow_redirects=False).status_code == 303
response = client.post(
"/auth/login",
data={"email": registered["email"], "password": registered["password"]},
follow_redirects=False,
)
assert response.status_code == 303
assert client.get("/chat").status_code == 200
def test_wrong_password_does_not_reveal_whether_the_account_exists(
client: TestClient, registered
):
client.post("/auth/logout", follow_redirects=False)
wrong = client.post(
"/auth/login",
data={"email": registered["email"], "password": "wrong-password-here"},
follow_redirects=False,
)
missing = client.post(
"/auth/login",
data={"email": "nobody@nowhere.test", "password": "wrong-password-here"},
follow_redirects=False,
)
assert wrong.status_code == missing.status_code == 401
assert "do not match" in wrong.text
# Identical wording is the whole point: the form must not be usable to
# enumerate which addresses are registered.
assert ("do not match" in missing.text) == ("do not match" in wrong.text)
def test_logout_revokes_the_session_immediately(client: TestClient, registered):
client.post("/auth/logout", follow_redirects=False)
assert client.get("/chat", follow_redirects=False).status_code == 303
def test_signed_out_pages_redirect_to_login(client: TestClient, registered):
client.post("/auth/logout", follow_redirects=False)
for path in ("/", "/chat", "/settings", "/admin/connections"):
assert client.get(path, follow_redirects=False).status_code == 303, path
def test_htmx_requests_get_a_redirect_header_not_a_login_page(
client: TestClient, registered
):
"""An htmx request must never swap a login form into a fragment of the UI."""
client.post("/auth/logout", follow_redirects=False)
response = client.post(
"/api/chats/start", data={"content": "hi"}, headers={"HX-Request": "true"}
)
assert response.status_code == 204
assert response.headers["HX-Redirect"] == "/auth/login"
def test_admin_area_is_closed_to_ordinary_users(client: TestClient, registered):
client.post("/auth/logout", follow_redirects=False)
client.post(
"/auth/register",
data={"name": "Sam", "email": "sam@shire.test", "password": "potatoes-po-ta-toes"},
follow_redirects=False,
)
assert client.get("/admin/connections", follow_redirects=False).status_code == 403
def test_login_next_parameter_cannot_be_used_for_an_open_redirect(
client: TestClient, registered
):
client.post("/auth/logout", follow_redirects=False)
response = client.post(
"/auth/login",
data={
"email": registered["email"],
"password": registered["password"],
"next": "https://evil.example.com/steal",
},
follow_redirects=False,
)
assert response.headers["location"] == "/"