working with login and building inventory

This commit is contained in:
2026-11-12 11:40:31 -07:00
parent d11e5e70c7
commit 0a93925e11
45 changed files with 2389 additions and 77 deletions

View File

Binary file not shown.

View File

@@ -1,4 +1,31 @@
"""Point the backend at the tiny fixture dataset before the app is imported."""
"""Test bootstrap.
Runs the suite against a throwaway in-memory SQLite database and the tiny
fixture catalog, so no PostgreSQL server is needed. Environment variables must
be set before any app module is imported, because config reads them at import
time.
"""
import os
os.environ["DATA_DIR"] = os.path.join(os.path.dirname(__file__), "fixtures")
os.environ["DATABASE_URL"] = "sqlite://"
os.environ["AUTO_INIT_DB"] = "false"
os.environ["COOKIE_SECURE"] = "false"
import pytest # noqa: E402
from app import db # noqa: E402
from app.models import Base # noqa: E402
@pytest.fixture(autouse=True)
def fresh_db():
"""Give every test an empty schema and a clean login throttle."""
engine = db.get_engine()
Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine)
from app.auth import throttle
throttle.clear()
yield

View File

@@ -0,0 +1,151 @@
"""Authentication tests: registration, login, sessions, throttling, CSRF."""
from fastapi.testclient import TestClient
from app.api import app
PASSWORD = "correct horse battery staple"
def new_client() -> TestClient:
return TestClient(app)
def register(client: TestClient, username: str = "alice", password: str = PASSWORD):
return client.post(
"/api/v1/auth/register", json={"username": username, "password": password}
)
def login(client: TestClient, username: str = "alice", password: str = PASSWORD):
return client.post(
"/api/v1/auth/login", json={"username": username, "password": password}
)
def test_register_returns_user_and_authenticates():
client = new_client()
resp = register(client)
assert resp.status_code == 201
body = resp.json()
assert body["user"]["username"] == "alice"
assert "password" not in body["user"]
assert client.cookies.get("sc_session")
me = client.get("/api/v1/auth/me")
assert me.status_code == 200
assert me.json()["user"]["username"] == "alice"
def test_register_duplicate_username_is_conflict_case_insensitive():
client = new_client()
assert register(client, "Alice").status_code == 201
assert register(new_client(), "alice").status_code == 409
def test_register_rejects_weak_password():
resp = register(new_client(), password="short")
assert resp.status_code == 422
def test_register_rejects_invalid_username():
for username in ["ab", "has space", "bad!", "x" * 33]:
assert register(new_client(), username).status_code == 422
def test_me_requires_authentication():
resp = new_client().get("/api/v1/auth/me")
assert resp.status_code == 401
def test_login_is_case_insensitive_and_works_after_logout():
client = new_client()
assert register(client, "alice").status_code == 201
assert client.post("/api/v1/auth/logout").status_code == 204
assert client.get("/api/v1/auth/me").status_code == 401
resp = login(client, "ALICE")
assert resp.status_code == 200
assert resp.json()["user"]["username"] == "alice"
assert client.get("/api/v1/auth/me").status_code == 200
def test_wrong_password_and_unknown_user_are_indistinguishable():
client = new_client()
assert register(client, "alice").status_code == 201
wrong = login(new_client(), "alice", "definitely wrong")
unknown = login(new_client(), "nobody", "definitely wrong")
assert wrong.status_code == unknown.status_code == 401
assert wrong.json()["detail"] == unknown.json()["detail"]
def test_logout_invalidates_session_token():
client = new_client()
assert register(client).status_code == 201
token = client.cookies.get("sc_session")
assert client.post("/api/v1/auth/logout").status_code == 204
# Replay the old cookie on a fresh client: it must be rejected.
replay = new_client()
replay.cookies.set("sc_session", token)
assert replay.get("/api/v1/auth/me").status_code == 401
def test_login_is_rate_limited():
client = new_client()
assert register(client, "alice").status_code == 201
for _ in range(5):
assert login(new_client(), "alice", "wrong password").status_code == 401
blocked = login(new_client(), "alice", PASSWORD)
assert blocked.status_code == 429
assert int(blocked.headers["Retry-After"]) > 0
def test_cross_origin_mutation_is_rejected():
client = new_client()
resp = client.post(
"/api/v1/auth/register",
json={"username": "alice", "password": PASSWORD},
headers={"Origin": "http://evil.example"},
)
assert resp.status_code == 403
def test_same_origin_mutation_is_allowed():
client = new_client()
resp = client.post(
"/api/v1/auth/register",
json={"username": "alice", "password": PASSWORD},
headers={"Origin": "http://testserver"},
)
assert resp.status_code == 201
def test_same_origin_with_forwarded_port_is_allowed():
client = new_client()
resp = client.post(
"/api/v1/auth/register",
json={"username": "alice", "password": PASSWORD},
headers={
"Origin": "http://testserver:8080",
"X-Forwarded-Host": "testserver:8080",
},
)
assert resp.status_code == 201
def test_forwarded_host_without_port_is_rejected():
client = new_client()
resp = client.post(
"/api/v1/auth/register",
json={"username": "alice", "password": PASSWORD},
headers={
"Origin": "http://testserver:8080",
"X-Forwarded-Host": "testserver",
},
)
assert resp.status_code == 403

View File

@@ -0,0 +1,191 @@
"""Personal inventory tests: auth gating, CRUD, isolation, catalog immutability."""
from fastapi.testclient import TestClient
from app.api import app
PASSWORD = "correct horse battery staple"
CARROCK = "ANVL_Carrack"
RIFLE = "BEHR_Rifle_Ballistic_01"
def authed_client(username: str = "alice") -> TestClient:
client = TestClient(app)
resp = client.post(
"/api/v1/auth/register", json={"username": username, "password": PASSWORD}
)
assert resp.status_code == 201, resp.text
return client
def add(client: TestClient, class_name: str, quantity: int = 1, notes: str = ""):
return client.post(
"/api/v1/me/inventory",
json={"class_name": class_name, "quantity": quantity, "notes": notes},
)
def test_new_account_starts_with_empty_inventory():
client = authed_client("freshpilot")
listing = client.get("/api/v1/me/inventory").json()
assert listing["total"] == 0
assert listing["items"] == []
assert client.get("/api/v1/me/inventory/summary").json() == {
"items": 0,
"quantity": 0,
"categories": {},
"unavailable": 0,
}
def test_inventory_requires_authentication():
client = TestClient(app)
assert client.get("/api/v1/me/inventory").status_code == 401
assert add(client, CARROCK).status_code == 401
assert client.get("/api/v1/me/inventory/summary").status_code == 401
def test_add_unknown_catalog_item_is_not_found():
client = authed_client()
assert add(client, "NOPE_Does_Not_Exist").status_code == 404
def test_add_creates_enriched_entry():
client = authed_client()
resp = add(client, CARROCK)
assert resp.status_code == 201
body = resp.json()
assert body["created"] is True
entry = body["entry"]
assert entry["class_name"] == CARROCK
assert entry["name"] == "Anvil Carrack"
assert entry["category"] == "ships"
assert entry["quantity"] == 1
assert entry["available"] is True
assert entry["manufacturer"]["code"] == "ANVL"
def test_add_same_item_increments_quantity():
client = authed_client()
assert add(client, CARROCK, quantity=1).status_code == 201
resp = add(client, CARROCK, quantity=2, notes="pledged")
assert resp.status_code == 200
body = resp.json()
assert body["created"] is False
assert body["entry"]["quantity"] == 3
assert body["entry"]["notes"] == "pledged"
listing = client.get("/api/v1/me/inventory").json()
assert listing["total"] == 1
def test_inventories_are_isolated_between_users():
alice = authed_client("alice")
bob = authed_client("bob")
assert add(alice, CARROCK).status_code == 201
assert add(bob, RIFLE).status_code == 201
alice_items = alice.get("/api/v1/me/inventory").json()["items"]
bob_items = bob.get("/api/v1/me/inventory").json()["items"]
assert [i["class_name"] for i in alice_items] == [CARROCK]
assert [i["class_name"] for i in bob_items] == [RIFLE]
def test_patch_updates_quantity_and_notes():
client = authed_client()
assert add(client, CARROCK).status_code == 201
resp = client.patch(
f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 4, "notes": "hangar 3"}
)
assert resp.status_code == 200
body = resp.json()
assert body["quantity"] == 4
assert body["notes"] == "hangar 3"
def test_patch_unknown_entry_is_not_found():
client = authed_client()
assert client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 2}).status_code == 404
def test_patch_rejects_zero_quantity():
client = authed_client()
assert add(client, CARROCK).status_code == 201
resp = client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 0})
assert resp.status_code == 422
def test_delete_removes_entry():
client = authed_client()
assert add(client, CARROCK).status_code == 201
assert client.delete(f"/api/v1/me/inventory/{CARROCK}").status_code == 204
assert client.get("/api/v1/me/inventory").json()["total"] == 0
assert client.delete(f"/api/v1/me/inventory/{CARROCK}").status_code == 404
def test_list_filters_by_category_and_search():
client = authed_client()
assert add(client, CARROCK).status_code == 201
assert add(client, RIFLE).status_code == 201
ships = client.get("/api/v1/me/inventory", params={"category": "ships"}).json()
assert ships["total"] == 1
assert ships["items"][0]["class_name"] == CARROCK
found = client.get("/api/v1/me/inventory", params={"search": "carrack"}).json()
assert found["total"] == 1
assert found["items"][0]["class_name"] == CARROCK
def test_list_pagination_envelope_and_cap():
client = authed_client()
assert add(client, CARROCK).status_code == 201
assert add(client, RIFLE).status_code == 201
body = client.get(
"/api/v1/me/inventory", params={"page_size": 1, "page": 2}
).json()
assert body["total"] == 2
assert body["page"] == 2
assert body["page_size"] == 1
assert body["total_pages"] == 2
assert len(body["items"]) == 1
capped = client.get("/api/v1/me/inventory", params={"page_size": 500}).json()
assert capped["page_size"] == 100
def test_summary_aggregates_quantity_and_categories():
client = authed_client()
assert add(client, CARROCK, quantity=2).status_code == 201
assert add(client, RIFLE, quantity=3).status_code == 201
summary = client.get("/api/v1/me/inventory/summary").json()
assert summary["items"] == 2
assert summary["quantity"] == 5
assert summary["unavailable"] == 0
assert summary["categories"]["ships"] == 2
def test_notes_length_is_bounded():
client = authed_client()
resp = add(client, CARROCK, notes="x" * 1001)
assert resp.status_code == 422
def test_catalog_is_not_mutated_by_inventory_operations():
client = authed_client()
before = client.get("/api/v1/items", params={"page_size": 100}).json()
assert add(client, CARROCK).status_code == 201
client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 9})
client.delete(f"/api/v1/me/inventory/{CARROCK}")
after = client.get("/api/v1/items", params={"page_size": 100}).json()
assert after == before
carrack = client.get(f"/api/v1/items/{CARROCK}").json()
assert "quantity" not in carrack
assert "notes" not in carrack