Files
beer_data/tests/test_app.py
T
hermes 1828dcb81d
Build and Push Image / Build and push image (push) Canceled after 0s
Build and Push Image / Unit tests (push) Canceled after 11m18s
fix(security): address the independent review's blocking findings
The independent reviewer returned "SIGNIFICANT CHANGES NEEDED" on the security
review, with the code review itself approved. This addresses the three
blocking items plus the legitimate medium-severity ones.

Blocking items
--------------

1. Weak password hashing (SHA-256 -> scrypt)

   The custom salted SHA-256 helper is replaced with
   `werkzeug.security.generate_password_hash` / `check_password_hash`, which
   use scrypt by default. The hash carries its own salt and is deliberately
   slow to compute, so it is not attackable with a GPU.

   The legacy plaintext accounts still work, but a successful legacy login now
   **rewrites the row in place** with a proper hash. The plaintext credential
   disappears from the database the first time the account is used.

   Verified against the live database: the `beer` account's plaintext
   `beer_pass` was replaced with a scrypt hash on first login, and login with
   the hash still succeeds.

2. Plaintext password persistence

   Fixed by the migrate-on-login above. There is no longer a code path that
   leaves a password in plaintext.

3. Hardcoded secret defaults

   `SECRET_KEY` no longer has a default. The app raises `ImproperlyConfigured`
   and refuses to start when it is missing or still set to a placeholder
   value. A known session-signing key would let anyone forge a login cookie,
   so a misconfigured deployment must fail loudly.

   The Dockerfile no longer ships a placeholder either — the variable is simply
   not set, so a deploy that forgets it fails immediately and visibly.

   Note: Werkzeug 3 removed `ImproperlyConfigured` and Flask has never
   exported one, so the app defines its own as a `RuntimeError` subclass.

Medium items
------------

4. NoSQL injection risk

   Every value that reaches a Mongo query is now explicitly coerced first:
   run ids and dates go through `_as_id` / `_as_date`, which turn anything
   that is not a plain string or number into a string (or an empty string).
   Without this, a caller passing `{"$ne": ""}` would have been interpreted
   by Mongo as a query operator rather than as a value.

   Two tests cover this: one drives the login path with an operator payload,
   one checks the coercion helper directly.

5. Dependency pinning

   `requirements.txt` now pins exact versions of the runtime dependencies, and
   `requirements-dev.txt` pins the test dependencies. The image is rebuilt on
   every merge, so an unpinned dependency could change behaviour underneath a
   deploy with no commit to show for it.

6. Template logic

   The `sg / 1000` arithmetic has been moved out of the Jinja templates and
   into the domain layer — `run_summary()` now provides `og_decimal_str`,
   `fg_decimal_str` and `min_sg_decimal`, pre-formatted. No template does
   arithmetic on the data any more.

7. Health check depth

   `/healthz` stays a shallow **liveness** probe that never touches Mongo — a
   database outage must not make the kubelet restart a healthy process. A new
   `/readyz` **readiness** probe pings Mongo and returns 503 when it is
   unreachable, so a pod with no database is pulled out of the Service
   endpoints instead of serving errors. The RUNBOOK documents wiring both.

Low items
---------

8. Mongo aggregation pipeline for `runs()`

   Not changed. The dataset is 47 documents across 18 runs and will stay in
   that range for years — the app exists to record a handful of readings per
   brew. Moving the grouping into an aggregation pipeline would add real
   complexity for no measurable gain, and the grouping in Python is what makes
   the run aggregation straightforward to test. Noted for whenever the data
   volume makes it matter.

9. Kubernetes Secret for the credentials

   Not changed here, and deliberately so. The live Deployment carries
   `MONGO_PASS` as a plain environment value. Replacing it means replacing
   hand-provisioned live values, which is an operator decision. The RUNBOOK
   now documents the Secret manifest to use when that is done, and includes a
   ready-to-paste probe configuration.

10. Rate limiting on `/login`, Flask-Talisman, password complexity

    Not added. This is a single-user home-brew tracker on a LAN, reachable
    through a NodePort. Rate limiting and security-header middleware are
    production-grade hardening that adds dependencies and configuration
    surface without addressing a realistic threat here — the credentials are
    `beer`/`beer_pass` on a service only the operator and their own scanning
    can reach. The one change that materially matters — the session-signing
    key — is now enforced.

11. Least-privilege Mongo user

    Not changed. The app connects as `root` today, matching the existing
    deployment. Creating a scoped user is a cluster-side operation and
    belongs with the credential work in item 9.

Tests
-----

73 tests (up from 64). New coverage: legacy password migration, hash
verification, empty-credential rejection, the NoSQL operator payload, the
secret-key guards, and the readiness probe in both states.
2026-11-09 10:28:15 +10:00

283 lines
9.5 KiB
Python

"""Tests for the web layer: login, the brew-process page and the API."""
from __future__ import annotations
import pytest
from conftest import seed_readings
@pytest.fixture()
def logged_in(client, repo):
"""A client with an authenticated session and a user in the database."""
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
response = client.post("/login", data={"username": "beer", "password": "beer_pass"},
follow_redirects=True)
assert response.status_code == 200
return client
# ------------------------------------------------------------------ public #
def test_healthz_is_public(client):
response = client.get("/healthz")
assert response.status_code == 200
assert response.json == {"status": "ok"}
def test_readyz_checks_the_database(client):
response = client.get("/readyz")
assert response.status_code == 200
assert response.json == {"status": "ready"}
def test_readyz_reports_not_ready_when_mongo_is_down(client, monkeypatch):
"""A pod that cannot reach Mongo must not serve traffic."""
from pymongo.errors import ServerSelectionTimeoutError
def boom(*args, **kwargs):
raise ServerSelectionTimeoutError("no mongo")
monkeypatch.setattr("mongo.repository.BeerRepository.__init__",
lambda self, **kw: (_ for _ in ()).throw(ServerSelectionTimeoutError("no mongo")))
response = client.get("/readyz")
assert response.status_code == 503
def test_root_redirects_to_login(client):
response = client.get("/")
assert response.status_code == 302
assert "/login" in response.headers["Location"]
def test_login_page_renders(client):
response = client.get("/login")
assert response.status_code == 200
assert b"Brew Log" in response.data
def test_login_with_bad_credentials_is_rejected(client, repo):
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
response = client.post("/login", data={"username": "beer", "password": "wrong"},
follow_redirects=True)
assert response.status_code == 200
assert b"not recognised" in response.data
def test_protected_pages_redirect_when_logged_out(client):
for path in ("/brews", "/runs/1"):
response = client.get(path)
assert response.status_code == 302, path
assert "/login" in response.headers["Location"], path
# ------------------------------------------------------------- brew pages #
def test_brews_page_lists_runs(logged_in, repo):
seed_readings(repo, [
("1", "Pale Ale", "1050", "2026-01-01", False, "start"),
("1", "Pale Ale", "1010", "2026-01-20", True, "Keghing"),
("2", "Ginger Beer", "1045", "2026-02-01", False, ""),
])
response = logged_in.get("/brews")
assert response.status_code == 200
body = response.data.decode()
assert "Pale Ale" in body
assert "Ginger Beer" in body
# The comment on the reading should be surfaced.
assert "Keghing" in body
def test_run_detail_page_renders(logged_in, repo):
seed_readings(repo, [
("1", "Pale Ale", "1050", "2026-01-01", False, "start"),
("1", "Pale Ale", "1010", "2026-01-20", True, "Keghing"),
])
response = logged_in.get("/runs/1")
assert response.status_code == 200
body = response.data.decode()
assert "Pale Ale" in body
assert "Keghing" in body
assert "5.25%" in body
# ------------------------------------------------------------- write paths #
def test_recording_a_reading_stores_it(logged_in, repo):
response = logged_in.post("/brews", data={
"beer_run_id": "5",
"beer_run_type": "Test Porter",
"Date": "2026-03-04",
"sg": "1045",
"comment": "Hops in",
}, follow_redirects=True)
assert response.status_code == 200
doc = repo.readings.find_one({"beer_run_id": "5"})
assert doc is not None
assert doc["sg"] == "1045"
assert doc["beer_type"] == "Test Porter"
assert doc["comment"] == "Hops in"
assert doc["final_reading"] == "False"
def test_recording_a_reading_twice_updates_in_place(logged_in, repo):
"""The unique key is (date, beer_run_id) — no duplicate rows."""
for sg in ("1045", "1035"):
logged_in.post("/brews", data={
"beer_run_id": "6",
"beer_run_type": "Test",
"Date": "2026-03-04",
"sg": sg,
})
count = repo.readings.count_documents({"beer_run_id": "6"})
doc = repo.readings.find_one({"beer_run_id": "6"})
assert count == 1
assert doc["sg"] == "1035"
def test_setting_a_final_reading_closes_the_run(logged_in, repo):
seed_readings(repo, [("7", "Ale", "1045", "2026-03-01", False, "")])
response = logged_in.post("/runs/7/final", data={"date": "2026-03-01"},
follow_redirects=True)
assert response.status_code == 200
doc = repo.readings.find_one({"beer_run_id": "7"})
assert doc["final_reading"] == "True"
def test_unsetting_a_final_reading_reactivates_the_run(logged_in, repo):
seed_readings(repo, [
("7", "Ale", "1045", "2026-03-01", False, ""),
("7", "Ale", "1010", "2026-03-20", True, ""),
])
logged_in.post("/runs/7/unfinal", data={"date": "2026-03-20"}, follow_redirects=True)
doc = repo.readings.find_one({"beer_run_id": "7", "date": "2026-03-20"})
assert doc["final_reading"] == "False"
# -------------------------------------------------------------------- API #
# ----------------------------------------------------------------- misc #
def test_static_assets_are_served(client):
for path in ("/static/js/app.js", "/static/css/app.css",
"/static/img/logo.svg"):
response = client.get(path)
assert response.status_code == 200, path
assert len(response.data) > 100, path
def test_logout_clears_the_session(logged_in):
response = logged_in.post("/logout", follow_redirects=True)
assert response.status_code == 200
# After logging out the protected page bounces back to the login page.
response = logged_in.get("/brews")
assert response.status_code == 302
assert "/login" in response.headers["Location"]
def test_out_of_range_reading_is_saved_but_warned(logged_in, repo):
response = logged_in.post("/brews", data={
"beer_run_id": "12",
"beer_run_type": "Odd",
"Date": "2026-05-01",
"sg": "950",
}, follow_redirects=True)
assert response.status_code == 200
assert b"outside the usual hydrometer range" in response.data
# It is still stored — the warning is advisory, not a rejection.
assert repo.readings.find_one({"beer_run_id": "12"}) is not None
# ------------------------------------------------------- security: passwords #
def test_legacy_plaintext_password_is_accepted_and_migrated(repo):
"""A legacy plaintext row still logs in, and is rewritten as a hash on
the way in so plaintext credentials disappear from the database."""
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
assert repo.user_check("beer", "beer_pass") is True
doc = repo.users.find_one({"username": "beer"})
assert doc["password"] != "beer_pass"
assert "$" in doc["password"] # a werkzeug hash
assert "password_migrated_at" in doc
def test_hashed_password_verifies(repo):
from mongo.repository import hash_password
repo.users.insert_one({"username": "beer", "password": hash_password("beer_pass")})
assert repo.user_check("beer", "beer_pass") is True
assert repo.user_check("beer", "wrong") is False
def test_user_check_rejects_empty_credentials(repo):
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
assert repo.user_check("", "") is False
assert repo.user_check("beer", "") is False
assert repo.user_check("", "beer_pass") is False
def test_user_check_rejects_a_nosql_operator_payload(repo):
"""A dict must never be able to act as a Mongo query operator."""
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
assert repo.user_check({"$ne": ""}, "anything") is False
assert repo.user_check({"$gt": ""}, {"$ne": ""}) is False
def test_run_id_cannot_be_used_as_an_operator(repo):
"""A run id is coerced to a string before it reaches a query."""
from mongo.repository import _as_id
assert _as_id({"$ne": ""}) == ""
assert _as_id(20) == "20"
assert _as_id("20") == "20"
def test_the_app_refuses_to_start_without_a_secret_key(monkeypatch):
"""A missing SECRET_KEY must stop the app, not fall back to a default."""
import importlib
import app_factory
# Reload so the guard reads the environment as it is *now*. The exception
# class must be taken from the reloaded module — reloading rebinds it, so
# a class imported earlier would be a different object and pytest.raises
# would not match it.
monkeypatch.delenv("SECRET_KEY", raising=False)
importlib.reload(app_factory)
with pytest.raises(app_factory.ImproperlyConfigured):
app_factory.create_app()
monkeypatch.setenv("SECRET_KEY", "test-secret-key-not-a-real-secret")
importlib.reload(app_factory)
def test_the_app_refuses_the_placeholder_secret_key(monkeypatch):
import importlib
import app_factory
monkeypatch.setenv("SECRET_KEY", "change-me-in-the-deployment")
importlib.reload(app_factory)
with pytest.raises(app_factory.ImproperlyConfigured):
app_factory.create_app()
monkeypatch.setenv("SECRET_KEY", "test-secret-key-not-a-real-secret")
importlib.reload(app_factory)