fix: build the image on Python 3.14, and fix the review findings
The image build failed on master:
ERROR: Ignored the following versions that require a different python
version: 4.19.0 Requires-Python >=3.11
ERROR: Could not find a version that satisfies the requirement
pymongo==4.19.0
The base image was `python:3.10` but the pinned pymongo 4.19 needs Python
>= 3.11. The image is now `python:3.14-slim` — the current stable release,
so it is not carrying the CVEs fixed in 3.11, 3.12 and 3.13.
All the runtime dependencies ship a cp314 wheel (pymongo included), so no
compiler toolchain is needed. The old apt-get line installed
libsasl2-dev, python-dev-is-python3, libldap2-dev and libssl-dev for
packages that are no longer in the dependency list, so it is gone and the
image is smaller.
The build now asserts the interpreter version, so a base image change that
breaks the pinned requirements fails the build with a clear message rather
than a pip resolution error.
Review findings, all from the independent review of this PR
------------------------------------------------------------
- Mongo indexes: the run metadata is always looked up by `beer_run_id` and
the readings by `(beer_run_id, date)`. Both are now indexed.
- Content-Security-Policy: the app serves only its own vendored assets, so
a strict `default-src 'self'` policy is enforceable. Adding it required
removing the inline `style=` attributes from the templates (they became
`.mb-lg` / `.mt-lg` utility classes) and the one inline `<script> (the run
id now travels as a `data-run-id` attribute). A test asserts no template
has either.
- Job timeouts: the `test` and `build` jobs now have `timeout-minutes`, so
a wedged build stops occupying a runner.
- OG range: raised from 1300 to 1500 points, so a strong specialty brew
(a 1.150 wort is real) can be recorded.
Bugs found and fixed along the way
----------------------------------
- An unfinished run reported a *measured* ABV. `abv_percent()` now returns
None for a run that is still in the fermenter — its figure is a projection,
not a measurement. This was showing the two active runs in the "alcohol by
brew" chart as if they were finished.
- The `/api/runs` endpoints the charts fetch from did not exist, so every
chart was silently empty. They are back, with tests.
- The "brews over time" chart left out the months with no brews, which
spaced the busy months evenly and made the chart read as continuous
activity. The quiet months are now included with a zero.
- The run-detail chart was dropped when PR 1 was scoped down, leaving dead
code behind. It is restored.
Tests: 105 (up from 91).
This commit is contained in:
@@ -473,3 +473,165 @@ def test_partial_metadata_update_ignores_none(repo):
|
||||
meta = repo.run_metadata("1")
|
||||
assert meta["name"] == "A Name"
|
||||
assert meta["tap_number"] == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------- security headers #
|
||||
|
||||
def test_security_headers_are_set(logged_in):
|
||||
"""The app serves only its own vendored assets, so a strict CSP of
|
||||
'self' is enforceable."""
|
||||
response = logged_in.get("/brews")
|
||||
headers = response.headers
|
||||
|
||||
assert headers.get("X-Content-Type-Options") == "nosniff"
|
||||
assert headers.get("X-Frame-Options") == "DENY"
|
||||
assert headers.get("Referrer-Policy") == "same-origin"
|
||||
|
||||
csp = headers.get("Content-Security-Policy")
|
||||
assert csp is not None
|
||||
assert "default-src 'self'" in csp
|
||||
assert "script-src 'self'" in csp
|
||||
assert "frame-ancestors 'none'" in csp
|
||||
# No CDN is used, so nothing external should be allowed.
|
||||
assert "https://" not in csp
|
||||
|
||||
|
||||
def test_security_headers_are_set_on_the_login_page(client):
|
||||
response = client.get("/login")
|
||||
assert response.headers.get("X-Content-Type-Options") == "nosniff"
|
||||
assert response.headers.get("Content-Security-Policy") is not None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ indexes #
|
||||
|
||||
def test_the_repository_creates_its_indexes(repo):
|
||||
"""The run metadata is always looked up by beer_run_id, and the readings
|
||||
by (beer_run_id, date) — both need an index."""
|
||||
index_names = repo.run_meta.index_information()
|
||||
assert "beer_run_id_1" in index_names
|
||||
|
||||
reading_indexes = repo.readings.index_information()
|
||||
assert "run_date_1" in reading_indexes
|
||||
|
||||
|
||||
def test_no_template_has_an_inline_script_or_style(client):
|
||||
"""The strict CSP (script-src 'self'; style-src 'self') blocks inline
|
||||
scripts and style attributes, so the templates must have neither."""
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
|
||||
template_dir = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"src", "flask", "templates",
|
||||
)
|
||||
|
||||
for path in glob.glob(os.path.join(template_dir, "*.html")):
|
||||
with open(path) as fh:
|
||||
body = fh.read()
|
||||
|
||||
# An inline <script> is one with content rather than a src attribute.
|
||||
for match in re.finditer(r"<script(?![^>]*\bsrc=)[^>]*>(.*?)</script>",
|
||||
body, re.S):
|
||||
assert match.group(1).strip() == "", (
|
||||
f"{os.path.basename(path)} has an inline <script>, which the "
|
||||
"CSP blocks"
|
||||
)
|
||||
|
||||
assert not re.search(r"\sstyle\s*=", body), (
|
||||
f"{os.path.basename(path)} has an inline style attribute, which "
|
||||
"the CSP blocks"
|
||||
)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- API #
|
||||
|
||||
def test_api_runs_returns_json(logged_in, repo):
|
||||
seed_readings(repo, [
|
||||
("1", "Pale Ale", "1050", "2026-01-01", False, ""),
|
||||
("1", "Pale Ale", "1010", "2026-01-20", True, ""),
|
||||
])
|
||||
|
||||
response = logged_in.get("/api/runs")
|
||||
assert response.status_code == 200
|
||||
payload = response.get_json()
|
||||
assert len(payload) == 1
|
||||
assert payload[0]["abv"] == 5.25
|
||||
assert payload[0]["active"] is False
|
||||
|
||||
|
||||
def test_api_run_detail_returns_json(logged_in, repo):
|
||||
seed_readings(repo, [("11", "Cider", "1045", "2026-04-01", False, "first")])
|
||||
|
||||
response = logged_in.get("/api/runs/11")
|
||||
assert response.status_code == 200
|
||||
payload = response.get_json()
|
||||
assert payload["beer_run_id"] == "11"
|
||||
assert payload["name"] == "Cider"
|
||||
assert len(payload["readings"]) == 1
|
||||
|
||||
|
||||
def test_api_run_detail_404s_for_an_unknown_run(logged_in):
|
||||
response = logged_in.get("/api/runs/does-not-exist")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_the_api_is_protected(client):
|
||||
"""The charts fetch from the API, so it must not be public."""
|
||||
for path in ("/api/runs", "/api/runs/1"):
|
||||
response = client.get(path)
|
||||
assert response.status_code == 302, path
|
||||
assert "/login" in response.headers["Location"], path
|
||||
|
||||
|
||||
def test_api_runs_includes_the_canonical_name_and_tap(logged_in, repo):
|
||||
seed_readings(repo, [("12", "Drifting Name", "1045", "2026-05-01", False, "")])
|
||||
repo.upsert_run_metadata("12", name="Canonical", tap_number=7)
|
||||
|
||||
payload = logged_in.get("/api/runs").get_json()
|
||||
entry = next(r for r in payload if r["beer_run_id"] == "12")
|
||||
assert entry["canonical_name"] == "Canonical"
|
||||
assert entry["tap_number"] == 7
|
||||
|
||||
|
||||
def test_the_months_chart_includes_the_gaps(logged_in, repo):
|
||||
"""Months with no brews must appear with a zero, or the chart spaces the
|
||||
busy months evenly and reads as continuous activity."""
|
||||
seed_readings(repo, [
|
||||
("1", "Spring", "1050", "2026-01-05", False, ""),
|
||||
("1", "Spring", "1010", "2026-01-20", True, ""),
|
||||
("2", "Autumn", "1050", "2026-06-05", False, ""),
|
||||
("2", "Autumn", "1010", "2026-06-20", True, ""),
|
||||
])
|
||||
|
||||
from views.analysis import _month_range
|
||||
|
||||
months = _month_range("2026-01", "2026-06")
|
||||
assert months == ["2026-01", "2026-02", "2026-03", "2026-04",
|
||||
"2026-05", "2026-06"]
|
||||
|
||||
|
||||
def test_the_months_chart_counts_zero_for_a_quiet_month(logged_in, repo):
|
||||
"""The chart is fed by the API, so the quiet months are visible there."""
|
||||
seed_readings(repo, [
|
||||
("1", "Spring", "1050", "2026-01-05", False, ""),
|
||||
("1", "Spring", "1010", "2026-01-20", True, ""),
|
||||
("2", "Autumn", "1050", "2026-06-05", False, ""),
|
||||
("2", "Autumn", "1010", "2026-06-20", True, ""),
|
||||
])
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
body = logged_in.get("/history").data.decode()
|
||||
# The page embeds the month labels for the chart.
|
||||
assert "2026-06" in body
|
||||
|
||||
|
||||
def test_month_range_covers_a_year_boundary():
|
||||
from views.analysis import _month_range
|
||||
|
||||
assert _month_range("2025-11", "2026-02") == [
|
||||
"2025-11", "2025-12", "2026-01", "2026-02",
|
||||
]
|
||||
assert _month_range("2026-05", "2026-05") == ["2026-05"]
|
||||
|
||||
Reference in New Issue
Block a user