fix(web): make closed cases fully viewable (discovery-gate, not access-gate)
Follow-up to 6d1ca71: that commit made the recordings sub-page render for a
closed case by threading ?show_closed=1, but the page's own links and the
audio player still 404'd — playback was dead and the back link led nowhere.
Root cause: the `.closed` marker was implemented as an ACCESS gate (every
case-scoped read 404s a closed case unless the opt-in flag is threaded
through), while it is documented as a DISCOVERY gate ("hidden from the
default listing"). Threading the flag through every link/redirect is
whack-a-mole; the next new link forgets it.
Resolved in favour of discovery-gate semantics: the `.closed` marker only
filters the default case LIST (scan_user_cases). Direct/deep-link access to
a known, owned case and all its sub-resources is closed-agnostic. IDOR is
unchanged — it comes from the per-user root + existence check, orthogonal
to closed-ness.
Scope 1 — closed cases are fully viewable:
- handle_audio: drop the is_closed -> 404 gate (web.rs). Audio is a
capability URL behind the same per-user path + slug/UUID/filename
validation as an open case.
- handle_case_page / handle_case_recordings: always resolve via
locate_closed_case_or_404; show_closed is no longer an access gate, so
back links (recordings -> case, case -> list) reach live pages without
threading the flag.
- the "back to list" links carry ?show_closed=1 when the case is closed
(computed server-side from the marker) so the user returns to the
closed-inclusive list, not the default list that hides the case.
Scope 2 — closed means read-only:
- case_page.html withholds the analyze / reset / re-analyze / retry forms
on closed cases; only the reopen affordance remains.
- case_recordings.html withholds the per-recording delete form on closed
cases (CaseRecordingsTemplate gains is_closed).
- the mutating handlers (analyze/reset/delete/oneliner) keep rejecting
closed cases via locate_case_or_404 as defense-in-depth behind the
now-hidden buttons.
Tests (RED-first):
- web_test: audio serves a closed case's file (200, not 404).
- case_page_test: closed case page + recordings render without
?show_closed=1; closed case hides analyze/reset; open case still shows
delete; closed case hides delete.
- analyze_test: closed_case_returns_404_on_detail rewritten to
closed_case_detail_renders_read_only — the old test pinned the
now-superseded access-gate contract.
cargo test (440 passed), clippy, and fmt all clean.
closes #17
This commit is contained in:
@@ -377,7 +377,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div><a class="back" href="/cases">← Fälle</a></div>
|
||||
<div><a class="back" href="/cases{% if is_closed %}?show_closed=1{% endif %}">← Fälle</a></div>
|
||||
<div class="header-right">
|
||||
{% if is_admin %}<label class="admin-toggle"
|
||||
><input type="checkbox" id="admin-view-toggle" /> Admin
|
||||
@@ -481,7 +481,7 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="actions">
|
||||
{% if can_analyze && !has_document %}
|
||||
{% if !is_closed && can_analyze && !has_document %}
|
||||
<form method="post" action="/cases/{{ case_id }}/analyze">
|
||||
{% call csrf::field(csrf_token) %}
|
||||
<input type="hidden" name="return_to" value="/cases/{{ case_id }}">
|
||||
@@ -497,9 +497,10 @@
|
||||
{% endfor %}
|
||||
</form>
|
||||
{% endif %}
|
||||
{% else if llm_missing && !has_document %}
|
||||
{% else if !is_closed && llm_missing && !has_document %}
|
||||
<span class="llm-missing">Kein LLM-Backend verfügbar — IONOS_API_KEY setzen.</span>
|
||||
{% endif %} {% if is_admin %}
|
||||
{% if !is_closed %}
|
||||
<form
|
||||
class="admin-only"
|
||||
method="post"
|
||||
@@ -509,6 +510,7 @@
|
||||
<input type="hidden" name="return_to" value="/cases/{{ case_id }}">
|
||||
<button type="submit">Reset</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<button
|
||||
type="button"
|
||||
id="debug-copy-btn"
|
||||
@@ -526,7 +528,7 @@
|
||||
<div class="stale-banner">Analyse nicht aktuell — neue Aufnahmen seit letztem Lauf.</div>
|
||||
{% endif %}
|
||||
<div id="doc-content" class="doc-content">
|
||||
{% if can_analyze %}
|
||||
{% if !is_closed && can_analyze %}
|
||||
<div class="doc-actions-top">
|
||||
<form method="post" action="/cases/{{ case_id }}/analyze">
|
||||
{% call csrf::field(csrf_token) %}
|
||||
@@ -698,6 +700,7 @@
|
||||
</dl>
|
||||
<pre class="failure-reason">{{ failure.reason }}</pre>
|
||||
</details>
|
||||
{% if !is_closed %}
|
||||
<form
|
||||
method="post"
|
||||
action="/cases/{{ case_id }}/analyze"
|
||||
@@ -729,6 +732,7 @@
|
||||
{% endfor %}
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% when None %} {% if analyzing %}
|
||||
<div class="placeholder">Wird analysiert …</div>
|
||||
@@ -744,7 +748,7 @@
|
||||
<div>
|
||||
<a
|
||||
class="recordings-link"
|
||||
href="/cases/{{ case_id }}/recordings{% if is_closed %}?show_closed=1{% endif %}"
|
||||
href="/cases/{{ case_id }}/recordings"
|
||||
>→ Aufnahmen anzeigen ({{ recordings_count }})</a
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -133,7 +133,7 @@ try {
|
||||
<body>
|
||||
<header>
|
||||
<div class="breadcrumb">
|
||||
<a href="/cases">← Fälle</a>
|
||||
<a href="/cases{% if is_closed %}?show_closed=1{% endif %}">← Fälle</a>
|
||||
{% match oneliner %}
|
||||
{% when Some with (t) %}
|
||||
<a href="/cases/{{ case_id }}">← {{ t }}</a>
|
||||
@@ -158,6 +158,7 @@ try {
|
||||
<input type="range" class="seek" min="0" max="1000" value="0" step="1" aria-label="Position">
|
||||
<span class="time">0:00 / 0:00</span>
|
||||
</div>
|
||||
{% if !is_closed %}
|
||||
<form method="post" action="/cases/{{ case_id }}/recordings/delete" class="rec-delete-form" data-ts="{{ rec.recorded_at_iso }}">
|
||||
{% call csrf::field(csrf_token) %}
|
||||
<input type="hidden" name="filename" value="{{ rec.filename }}">
|
||||
@@ -168,6 +169,7 @@ try {
|
||||
<button type="button" class="cancel-btn" aria-label="Abbrechen">✕</button>
|
||||
</span>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if is_admin %}<div class="filename admin-only">{{ rec.filename }}</div>{% endif %}
|
||||
{% if rec.failed %}
|
||||
|
||||
Reference in New Issue
Block a user