2f978d84d1
Deploying knowledge bases showed the migration runner doing the wrong thing:
ALTER TABLE "documents" ADD COLUMN "base_id" VARCHAR(32) DEFAULT ''
`base_id` is nullable and its absent value is NULL, but the runner derived a
default from the column type and backfilled every existing row with the empty
string. Nothing then matched `base_id IS NULL`, so the startup sweep that files
pre-bases documents into a default base would have skipped all of them and the
documents would have stayed invisible.
Nobody lost anything -- the live instance had no documents yet -- but the fault
is general: any nullable column added from here would arrive as "" rather than
NULL, and every "is this set?" check would be wrong about the rows that predate
it. So a default is now emitted only for NOT NULL columns, where SQLite requires
one.
The sweep also accepts "" as meaning unfiled, since a deployment that upgraded
through the previous release has rows holding it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
234 lines
8.1 KiB
Python
234 lines
8.1 KiB
Python
"""Instance settings (registration toggle) and changing your own password."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy import select
|
|
|
|
from lembas.db.models import Session as SessionRow
|
|
from lembas.db.models import User
|
|
from lembas.services import settings_store
|
|
|
|
|
|
# --- Registration toggle -----------------------------------------------------
|
|
def test_registration_is_open_by_default(client: TestClient, db, registered):
|
|
assert settings_store.signup_allowed(db) is True
|
|
|
|
|
|
def test_closing_registration_blocks_new_accounts(client: TestClient, db, registered):
|
|
client.post("/admin/general", data={"instance_name": "LLeMbas"}, follow_redirects=False)
|
|
assert settings_store.signup_allowed(db) is False
|
|
|
|
response = client.post(
|
|
"/auth/register",
|
|
data={"name": "Uninvited", "email": "no@thanks.test", "password": "let-me-in-please"},
|
|
follow_redirects=False,
|
|
)
|
|
assert response.status_code == 403
|
|
assert "Registration is closed" in response.text
|
|
assert db.scalar(select(User).where(User.email == "no@thanks.test")) is None
|
|
|
|
|
|
def test_closed_registration_hides_the_create_account_link(
|
|
client: TestClient, db, registered
|
|
):
|
|
client.post("/admin/general", data={"instance_name": "LLeMbas"}, follow_redirects=False)
|
|
client.post("/auth/logout", follow_redirects=False)
|
|
|
|
page = client.get("/auth/login")
|
|
assert "/auth/register" not in page.text
|
|
|
|
|
|
def test_open_registration_shows_the_link(client: TestClient, db, registered):
|
|
client.post(
|
|
"/admin/general",
|
|
data={"instance_name": "LLeMbas", "allow_signup": "true"},
|
|
follow_redirects=False,
|
|
)
|
|
client.post("/auth/logout", follow_redirects=False)
|
|
assert "/auth/register" in client.get("/auth/login").text
|
|
|
|
|
|
def test_existing_users_can_still_sign_in_when_registration_is_closed(
|
|
client: TestClient, db, registered
|
|
):
|
|
client.post("/admin/general", data={"instance_name": "LLeMbas"}, follow_redirects=False)
|
|
client.post("/auth/logout", follow_redirects=False)
|
|
|
|
response = client.post(
|
|
"/auth/login",
|
|
data={"email": registered["email"], "password": registered["password"]},
|
|
follow_redirects=False,
|
|
)
|
|
assert response.status_code == 303
|
|
|
|
|
|
def test_stored_setting_beats_the_environment_default(client: TestClient, db, registered):
|
|
"""A toggle that silently reverted on restart would be worse than none."""
|
|
from lembas.config import settings as env_settings
|
|
|
|
assert env_settings.allow_signup is True
|
|
settings_store.update(db, {"allow_signup": False})
|
|
assert settings_store.signup_allowed(db) is False
|
|
|
|
|
|
def test_ordinary_users_cannot_change_instance_settings(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,
|
|
)
|
|
assert client.post("/admin/general", data={"instance_name": "Pwned"}).status_code == 403
|
|
assert settings_store.get(db, "instance_name") == "LLeMbas"
|
|
|
|
|
|
# --- Password change ---------------------------------------------------------
|
|
def test_password_change_works(client: TestClient, db, registered):
|
|
response = client.post(
|
|
"/api/preferences/password",
|
|
data={
|
|
"current_password": registered["password"],
|
|
"new_password": "a-much-better-password",
|
|
"confirm_password": "a-much-better-password",
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert response.status_code == 303
|
|
assert "saved=" in response.headers["location"]
|
|
|
|
client.post("/auth/logout", follow_redirects=False)
|
|
assert (
|
|
client.post(
|
|
"/auth/login",
|
|
data={"email": registered["email"], "password": "a-much-better-password"},
|
|
follow_redirects=False,
|
|
).status_code
|
|
== 303
|
|
)
|
|
|
|
|
|
def test_old_password_stops_working(client: TestClient, db, registered):
|
|
client.post(
|
|
"/api/preferences/password",
|
|
data={
|
|
"current_password": registered["password"],
|
|
"new_password": "a-much-better-password",
|
|
"confirm_password": "a-much-better-password",
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
client.post("/auth/logout", follow_redirects=False)
|
|
assert (
|
|
client.post(
|
|
"/auth/login",
|
|
data={"email": registered["email"], "password": registered["password"]},
|
|
follow_redirects=False,
|
|
).status_code
|
|
== 401
|
|
)
|
|
|
|
|
|
def test_wrong_current_password_is_refused(client: TestClient, db, registered):
|
|
response = client.post(
|
|
"/api/preferences/password",
|
|
data={
|
|
"current_password": "not-my-password",
|
|
"new_password": "a-much-better-password",
|
|
"confirm_password": "a-much-better-password",
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert "error=" in response.headers["location"]
|
|
|
|
user = db.scalar(select(User).where(User.email == registered["email"]))
|
|
db.refresh(user)
|
|
from lembas.security.passwords import verify_password
|
|
|
|
assert verify_password(registered["password"], user.password_hash)
|
|
|
|
|
|
def test_mismatched_confirmation_is_refused(client: TestClient, db, registered):
|
|
response = client.post(
|
|
"/api/preferences/password",
|
|
data={
|
|
"current_password": registered["password"],
|
|
"new_password": "a-much-better-password",
|
|
"confirm_password": "a-different-password",
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert "do%20not%20match" in response.headers["location"]
|
|
|
|
|
|
def test_short_new_password_is_refused(client: TestClient, db, registered):
|
|
response = client.post(
|
|
"/api/preferences/password",
|
|
data={
|
|
"current_password": registered["password"],
|
|
"new_password": "short",
|
|
"confirm_password": "short",
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert "8%20characters" in response.headers["location"]
|
|
|
|
|
|
def test_other_sessions_are_revoked_but_this_one_survives(client: TestClient, db, registered):
|
|
"""If the reason for changing a password is that someone else knows it,
|
|
leaving their session alive defeats the point."""
|
|
other = TestClient(client.app)
|
|
other.post(
|
|
"/auth/login",
|
|
data={"email": registered["email"], "password": registered["password"]},
|
|
follow_redirects=False,
|
|
)
|
|
assert other.get("/chat", follow_redirects=False).status_code == 200
|
|
assert db.scalar(select(SessionRow)) is not None
|
|
|
|
client.post(
|
|
"/api/preferences/password",
|
|
data={
|
|
"current_password": registered["password"],
|
|
"new_password": "a-much-better-password",
|
|
"confirm_password": "a-much-better-password",
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
# The other browser is out...
|
|
assert other.get("/chat", follow_redirects=False).status_code == 303
|
|
# ...and the tab that made the change is still in.
|
|
assert client.get("/chat", follow_redirects=False).status_code == 200
|
|
|
|
|
|
# --- Adding a column to a table that already has rows -------------------------
|
|
def test_a_nullable_column_is_added_without_a_default(db):
|
|
"""Backfilling one would give existing rows a value the model does not treat
|
|
as absent: an added foreign key arrives as "" rather than NULL, and every
|
|
"is this set?" check downstream is then wrong about the old rows."""
|
|
from sqlalchemy import Column, String
|
|
|
|
from lembas.db.migrations import _add_column_sql
|
|
from lembas.db.models import Document
|
|
|
|
sql = _add_column_sql(
|
|
Document.__table__, Column("later", String(32), nullable=True), db.bind.dialect
|
|
)
|
|
assert "DEFAULT" not in sql
|
|
|
|
|
|
def test_a_not_null_column_still_gets_one(db):
|
|
"""SQLite refuses NOT NULL with no default, so there it is unavoidable."""
|
|
from sqlalchemy import Column, String
|
|
|
|
from lembas.db.migrations import _add_column_sql
|
|
from lembas.db.models import Document
|
|
|
|
sql = _add_column_sql(
|
|
Document.__table__,
|
|
Column("later", String(32), nullable=False, default=""),
|
|
db.bind.dialect,
|
|
)
|
|
assert "NOT NULL" in sql and "DEFAULT" in sql
|