The route lives under the gated prefix from IDENTITY-CONTRACT.md section 5, so gating it later is a proxy change and not a code change. It ships OPEN, because /m/ is not yet gated and no membership system exists to gate it -- recorded in section 8 of that document and cited in the module docstring, so a successor reads it as a decision rather than an oversight. model/stl with a Content-Disposition filename carrying the input_id. The browser saves it without JavaScript assembling a blob on the happy path. The route rebuilds from the query parameters rather than caching what /api/build just made: the file is a function of the URL, and the composer goes on holding no state between requests. Persistence is its own piece of work and should not arrive here by accident. MECHCOMP_MAX_EXPORT_MM bounds it, defaulting to 3048 -- one ten-foot member. The ceiling exists because this endpoint is unauthenticated on a public name and member_length_ft is an unbounded number whose value alone decides the size of the computation and the download. /api/build has the same exposure with a constant-size answer; export is where bounding becomes worth it. 3048 is not a claim that longer members are wrong. A hundred-foot member is a real artifact and nobody prints one in a piece -- it gets sectioned. The export ceiling and the member catalogue answer different questions and conflating them would be the mistake. The limit is configuration rather than a query parameter because a limit the caller can raise is not a limit, and an unparseable or non-positive setting falls back to the default rather than disabling the bound: a typo must not leave a limit that exists in the documentation and nowhere else. Over the ceiling is a 413 naming the length, the limit and the key, and it refuses rather than truncates. A truncated export would ship a 3048 mm file whose design record describes a 30480 mm member -- the same silent wrongness the length_view control was added to prevent, arriving by a different door. ProfileRejected is a 422 with the reason intact, an unknown family a 404, and only a genuinely unexpected exception a 500. Refusals are text/plain so the download control can show them and a person who hit the URL by hand can read it. A rejection is the compiler working. Two things changed while building rather than after. The type coercion was extracted from build_payload into typed_overrides and is now shared: had the export coerced differently from the view, the downloaded file would not be the part on screen, and neither would have looked wrong on its own. And the button re-sends the query the current drawing came from rather than reading the controls when pressed, so a half-typed number in a text box cannot export something that was never displayed. The page's JavaScript was parsed with node --check before landing. The download handler rebalanced braces around the data.ok block, which is exactly the kind of edit that compiles as a Python string and breaks in a browser. 16 tests. Mutation-proven: never applying the ceiling fails 4, truncating instead of refusing fails 4, a bad environment value disabling the bound fails 5, the export using its own coercion fails 1, a rejection becoming a 500 fails 2. Restoration verified by checksum, PYTHONDONTWRITEBYTECODE=1 throughout. One narrow margin worth recording: the shared-coercion guarantee rests on a single test, test_the_export_is_the_part_the_view_shows. It is the only thing that failed under M4. Deleting it would silently remove the only check that the file matches the drawing. Suite 643 passed.
182 lines
6.5 KiB
Python
182 lines
6.5 KiB
Python
"""
|
|
The export route: what it serves, what it refuses, and what it will not truncate.
|
|
|
|
WHY THE CEILING IS TESTED AS HARD AS THE GEOMETRY
|
|
``/m/stl`` is reachable without authentication on a public name, and
|
|
``member_length_ft`` is an unbounded number. The response body grows with
|
|
nothing but that parameter. The bound is the only thing standing between a
|
|
short URL and a very large computation, so a bound that silently stopped
|
|
applying would be worse than none -- it would be documented, believed, and
|
|
absent.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import struct
|
|
|
|
import pytest
|
|
|
|
app = pytest.importorskip("mechcomp.web.app")
|
|
stl = pytest.importorskip("mechcomp.stl")
|
|
|
|
CEILING = app.DEFAULT_MAX_EXPORT_MM
|
|
|
|
|
|
def export(ceiling=None, **overrides):
|
|
return app.build_export("3x", "Y", dict(overrides), "", ceiling)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# It serves a file
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_a_default_export_is_a_named_stl():
|
|
filename, data = export()
|
|
assert filename.endswith(".stl")
|
|
assert len(data) == 84 + 50 * struct.unpack("<I", data[80:84])[0]
|
|
|
|
|
|
def test_the_filename_carries_the_design_id():
|
|
from mechcomp.profiles import build
|
|
filename, _ = export()
|
|
assert build("3x", "Y").record.input_id in filename
|
|
|
|
|
|
def test_the_header_identifies_the_design():
|
|
_, data = export()
|
|
text = data[:80].rstrip(b"\0").decode("ascii")
|
|
assert text.startswith("mechcomp")
|
|
assert "input=" in text and "build=" in text
|
|
|
|
|
|
def test_the_export_is_the_part_the_view_shows():
|
|
"""
|
|
Both go through ``typed_overrides``. If they coerced differently the file
|
|
would not be the drawing, and neither would look wrong on its own -- which
|
|
is why the coercion is shared rather than written twice.
|
|
"""
|
|
params = {"preview_length_mm": "37.5", "bundle_count": "2"}
|
|
payload = app.build_payload("3x", "Y", dict(params))
|
|
_, data = export(**params)
|
|
|
|
zs = []
|
|
count = struct.unpack("<I", data[80:84])[0]
|
|
for n in range(count):
|
|
base = 84 + n * 50 + 12
|
|
for v in range(3):
|
|
zs.append(struct.unpack("<3f", data[base + v * 12:base + v * 12 + 12])[2])
|
|
assert max(zs) == pytest.approx(payload["report"]["LENGTH_MM"], rel=1e-6)
|
|
assert payload["values"]["bundle_count"] == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The ceiling
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_the_default_ceiling_is_one_ten_foot_member():
|
|
assert CEILING == 3048.0
|
|
|
|
|
|
def test_a_member_at_the_ceiling_exports():
|
|
"""The boundary is inclusive, and this is the positive control for below."""
|
|
filename, data = export(length_view="Full Length", member_length_ft=10)
|
|
assert filename.endswith(".stl")
|
|
assert len(data) > 84
|
|
|
|
|
|
def test_a_member_over_the_ceiling_is_refused():
|
|
outcome = export(length_view="Full Length", member_length_ft=100)
|
|
assert isinstance(outcome, app.ExportRefusal)
|
|
assert outcome.status == 413
|
|
|
|
|
|
def test_the_refusal_names_the_limit_and_how_to_raise_it():
|
|
"""
|
|
A refusal a person cannot act on is a wall. This one says the length, the
|
|
limit, and the configuration key.
|
|
"""
|
|
outcome = export(length_view="Full Length", member_length_ft=100)
|
|
assert "30480" in outcome.message
|
|
assert "3048" in outcome.message
|
|
assert "MECHCOMP_MAX_EXPORT_MM" in outcome.message
|
|
|
|
|
|
def test_nothing_is_truncated_to_fit():
|
|
"""
|
|
The failure this prevents: exporting a 3048 mm file for a 30480 mm request
|
|
would ship a design record describing a different object -- the same class
|
|
of silent wrongness the length_view control was added to stop, arriving by
|
|
a different door.
|
|
"""
|
|
outcome = export(length_view="Full Length", member_length_ft=100)
|
|
assert isinstance(outcome, app.ExportRefusal)
|
|
assert "truncated" in outcome.message.lower()
|
|
|
|
|
|
def test_the_ceiling_is_configurable():
|
|
"""Raising it lets the same member through, which proves the gate is the
|
|
ceiling rather than something else about a long member."""
|
|
assert isinstance(export(length_view="Full Length", member_length_ft=100),
|
|
app.ExportRefusal)
|
|
filename, data = export(ceiling=1e9, length_view="Full Length",
|
|
member_length_ft=100)
|
|
assert filename.endswith(".stl")
|
|
|
|
|
|
def test_a_broken_ceiling_setting_falls_back_to_the_default(monkeypatch, tmp_path):
|
|
"""
|
|
A typo must not silently remove the bound. That is the failure where a
|
|
limit exists in the documentation and nowhere else.
|
|
"""
|
|
empty = tmp_path / "none.env"
|
|
empty.write_text("")
|
|
for bad in ("", "abc", "0", "-5", " "):
|
|
monkeypatch.setenv("MECHCOMP_MAX_EXPORT_MM", bad)
|
|
assert app.max_export_mm(str(empty)) == CEILING
|
|
monkeypatch.setenv("MECHCOMP_MAX_EXPORT_MM", "5000")
|
|
assert app.max_export_mm(str(empty)) == 5000.0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Refusals are readable, not stack traces
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_an_unknown_family_is_a_404():
|
|
outcome = app.build_export("9x", "Y", {}, "")
|
|
assert isinstance(outcome, app.ExportRefusal)
|
|
assert outcome.status == 404
|
|
|
|
|
|
def test_an_unknown_profile_is_not_a_500():
|
|
outcome = app.build_export("3x", "Nonexistent", {}, "")
|
|
assert isinstance(outcome, app.ExportRefusal)
|
|
assert outcome.status < 500
|
|
|
|
|
|
def test_a_rejected_profile_is_a_422_carrying_the_reason():
|
|
"""
|
|
A rejection is the compiler working. The message names the parameter and
|
|
the limit, as the reference did, and must survive to the caller.
|
|
"""
|
|
outcome = export(min_wall_mm=50)
|
|
assert isinstance(outcome, app.ExportRefusal)
|
|
assert outcome.status == 422
|
|
assert outcome.message
|
|
assert "Traceback" not in outcome.message
|
|
|
|
|
|
def test_a_buildable_member_is_not_refused():
|
|
"""Positive control: the refusals are not simply refusing everything."""
|
|
assert not isinstance(export(), app.ExportRefusal)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Authorship travels with the export
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_an_author_reaches_the_record_without_moving_the_file_identity():
|
|
plain = app.build_export("3x", "Y", {}, "")
|
|
signed = app.build_export("3x", "Y", {}, "someone@example.org")
|
|
assert plain[0] == signed[0], "input_id must not move with the author"
|
|
assert plain[1][:80] == signed[1][:80], "neither id may move"
|