fix(books): ErrNoMatch on Fetch Metadata shows calm 'no match' instead of scary 404 (bookshelf-zedtf) #1245

Merged
zombor merged 1 commit from bd-bookshelf-zedtf into main 2026-07-26 19:14:04 +00:00
Owner

Summary

  • RefetchMetadataHandler previously mapped metadata.ErrNoMatchmiddleware.ErrNotFound → HTTP 404 {"error":"Not Found"}. The JS controller showed "Error: server returned 404" when providers had no match for a book — a legitimate, non-error outcome presented as a broken endpoint.
  • Handler now returns HTTP 200 {"matched":false,"cover_pending":false} (JSON) or redirects to /books/{id}?no_match=1 (browser) on ErrNoMatch. All other errors still propagate unchanged.
  • refetchMetadataResponse gains a Matched bool field: true on successful match, false on no-match.
  • JS Stimulus controller reads matched===false and shows neutral status "No metadata match found for this book", re-enables button, and does NOT navigate. Only non-2xx/network errors show "Error: server returned N".
  • Extracts refetchNoMatchResponse + refetchMatchResponse helpers to keep RefetchMetadataHandler within the funlen gate.
  • Audited other metadata endpoints: FetchCandidates already handles ErrNoMatch gracefully (empty candidates list, not 404). No sibling bead needed.

Test plan

  • Go handler unit tests: ErrNoMatch → 200 + {matched:false} (NOT 404); successful match → 200 + {matched:true}; non-ErrNoMatch errors still propagate; browser redirect paths both tested
  • Go handler unit test: encode error path for no-match JSON response covered via failWriter
  • JS Vitest tests: matched:false → neutral status + button re-enabled + no navigation; matched:true + cover_pending:true → navigates with ?cover_pending=1; matched:true + cover_pending:false → navigates with ?refreshed=1; non-2xx still shows error
  • make coverage passes (100% on internal/)
  • make lint clean on this worktree's files

Closes bead bookshelf-zedtf on merge.

## Summary - `RefetchMetadataHandler` previously mapped `metadata.ErrNoMatch` → `middleware.ErrNotFound` → HTTP 404 `{"error":"Not Found"}`. The JS controller showed "Error: server returned 404" when providers had no match for a book — a legitimate, non-error outcome presented as a broken endpoint. - Handler now returns HTTP 200 `{"matched":false,"cover_pending":false}` (JSON) or redirects to `/books/{id}?no_match=1` (browser) on `ErrNoMatch`. All other errors still propagate unchanged. - `refetchMetadataResponse` gains a `Matched bool` field: `true` on successful match, `false` on no-match. - JS Stimulus controller reads `matched===false` and shows neutral status "No metadata match found for this book", re-enables button, and does NOT navigate. Only non-2xx/network errors show "Error: server returned N". - Extracts `refetchNoMatchResponse` + `refetchMatchResponse` helpers to keep `RefetchMetadataHandler` within the `funlen` gate. - Audited other metadata endpoints: `FetchCandidates` already handles `ErrNoMatch` gracefully (empty candidates list, not 404). No sibling bead needed. ## Test plan - [x] Go handler unit tests: `ErrNoMatch` → 200 + `{matched:false}` (NOT 404); successful match → 200 + `{matched:true}`; non-`ErrNoMatch` errors still propagate; browser redirect paths both tested - [x] Go handler unit test: encode error path for no-match JSON response covered via `failWriter` - [x] JS Vitest tests: `matched:false` → neutral status + button re-enabled + no navigation; `matched:true` + `cover_pending:true` → navigates with `?cover_pending=1`; `matched:true` + `cover_pending:false` → navigates with `?refreshed=1`; non-2xx still shows error - [x] `make coverage` passes (100% on `internal/`) - [x] `make lint` clean on this worktree's files Closes bead bookshelf-zedtf on merge.
fix(books): ErrNoMatch on Fetch Metadata shows calm 'no match' instead of scary 404
All checks were successful
/ JS Unit Tests (pull_request) Successful in 59s
/ E2E API (pull_request) Successful in 4m29s
/ Test Race (pull_request) Successful in 5m0s
/ Coverage (pull_request) Successful in 5m14s
/ Lint (pull_request) Successful in 5m31s
/ E2E Browser (pull_request) Successful in 5m41s
/ Integration (pull_request) Successful in 5m55s
3e3c9290ff
When all metadata providers return no match for a book, the handler now
responds with HTTP 200 {"matched":false,"cover_pending":false} (JSON) or
redirects to /books/{id}?no_match=1 (browser) instead of propagating
middleware.ErrNotFound which rendered as "Error: server returned 404".

The JS Stimulus controller reads matched===false and shows a neutral
"No metadata match found for this book" status message, re-enables the
button, and does not navigate — distinguishing a legitimate no-match from
a real server error.

Also adds the Matched bool field to refetchMetadataResponse so JSON
clients can distinguish match vs no-match outcomes. On a successful
match, matched=true is now explicitly set.

Extracts refetchNoMatchResponse and refetchMatchResponse helpers to keep
RefetchMetadataHandler within the funlen gate.

Closes bead bookshelf-zedtf on merge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

Security review — PR #1239 (bd-bookshelf-cs2zl.2)

New POST /books/bulk/filter/scan-file (LLM scan-file over all filter-matching / whole-library books) + library kebab trigger.

Multi-user scoping — PASS (fail-closed). bulkScanFileFilterRequest reuses the shared bulkFilterHandler: userID is taken from the session (userIDFromRequest(r)), never the body; library_id/shelf_id/status are re-scoped to that userID (ShelfUserID/StatusUserID); magic_shelf_id is ownership-checked via checkMagicShelfAccess (404 on miss). Book-ID resolution re-resolves userLibraryIDs per ContinueAsNew epoch in buildListFilteredIDsPageFn and passes them to ListFilteredBookIDsPage, whose predicate is fail-closed (internal/books/filter_predicates.go:104library_id IN (...); non-nil empty → 1=0). users.GetUserLibraryIDs normalizes nil → []int64{}, so a zero-library user takes the 1=0 branch — a body-supplied library_id the user cannot access yields no rows, not a cross-user leak.

Auth — PASS. Route is gated g.BulkScanFile(...)BookBulkScanFileRequiredusers.PermissionRequired(..., PermissionBulkAutoFetchMetadata) (internal/app/app.go:355), same real permission as the sibling by-IDs endpoint. Not "any logged-in user."

Resource-exhaustion / cost DoS — PASS. Fan-out is bounded single-digit (defaultFanOutConcurrency = 4, internal/wfengine/fanout.go:19) and sub-workflows route to the LLM queue (scanFileFanOutOptions) so vision activities respect the GPU/concurrency cap; the kebab entry is gated behind {{if $.LLMVisionAvailable}} and a dialog.confirm count prompt.

Injection — PASS. All SQL is sqlc/parameterized; view_query validated via ParseViewQueryFilter at the boundary; status/format/metadata filters allowlist-validated; audit action is a constant.

Workflow versioning — SAFE (no gate needed). The new case BulkFilterOpScanFile in bulkByFilterApplyOp is selected by the per-instance-immutable input.Op; in-flight instances carry a different Op and keep their original command sequence, so replay does not diverge.

Findings

[MINOR] templates/layouts/base.html:200 — count-confirmation shows 0 for the largest libraries
data-...-book-count-value="{{if .HasCount}}{{.Count}}{{else}}0{{end}}" falls back to 0 when the count is unavailable (HasCount false) — which per the Scale convention is exactly the large/unfiltered libraries where the "you are about to scan N files … uses your LLM budget" confirmation matters most. The most expensive case shows the least alarming number, weakening the secondary cost guard. Authorization/scoping/permission gates still fully protect the operation, so this is UX-quality, not a vulnerability. Suggest an indeterminate message ("all files in this library") when HasCount is false rather than "0 files".

REVIEW VERDICT: 0 blocker, 0 major, 1 minor

## Security review — PR #1239 (`bd-bookshelf-cs2zl.2`) New `POST /books/bulk/filter/scan-file` (LLM scan-file over all filter-matching / whole-library books) + library kebab trigger. **Multi-user scoping — PASS (fail-closed).** `bulkScanFileFilterRequest` reuses the shared `bulkFilterHandler`: `userID` is taken from the session (`userIDFromRequest(r)`), never the body; `library_id`/`shelf_id`/`status` are re-scoped to that userID (`ShelfUserID`/`StatusUserID`); `magic_shelf_id` is ownership-checked via `checkMagicShelfAccess` (404 on miss). Book-ID resolution re-resolves `userLibraryIDs` per ContinueAsNew epoch in `buildListFilteredIDsPageFn` and passes them to `ListFilteredBookIDsPage`, whose predicate is fail-closed (`internal/books/filter_predicates.go:104` → `library_id IN (...)`; non-nil empty → `1=0`). `users.GetUserLibraryIDs` normalizes `nil → []int64{}`, so a zero-library user takes the `1=0` branch — a body-supplied `library_id` the user cannot access yields no rows, not a cross-user leak. **Auth — PASS.** Route is gated `g.BulkScanFile(...)` → `BookBulkScanFileRequired` → `users.PermissionRequired(..., PermissionBulkAutoFetchMetadata)` (`internal/app/app.go:355`), same real permission as the sibling by-IDs endpoint. Not "any logged-in user." **Resource-exhaustion / cost DoS — PASS.** Fan-out is bounded single-digit (`defaultFanOutConcurrency = 4`, `internal/wfengine/fanout.go:19`) and sub-workflows route to the LLM queue (`scanFileFanOutOptions`) so vision activities respect the GPU/concurrency cap; the kebab entry is gated behind `{{if $.LLMVisionAvailable}}` and a `dialog.confirm` count prompt. **Injection — PASS.** All SQL is sqlc/parameterized; `view_query` validated via `ParseViewQueryFilter` at the boundary; status/format/metadata filters allowlist-validated; audit action is a constant. **Workflow versioning — SAFE (no gate needed).** The new `case BulkFilterOpScanFile` in `bulkByFilterApplyOp` is selected by the per-instance-immutable `input.Op`; in-flight instances carry a different `Op` and keep their original command sequence, so replay does not diverge. ### Findings [MINOR] templates/layouts/base.html:200 — count-confirmation shows 0 for the largest libraries `data-...-book-count-value="{{if .HasCount}}{{.Count}}{{else}}0{{end}}"` falls back to 0 when the count is unavailable (`HasCount` false) — which per the Scale convention is exactly the large/unfiltered libraries where the "you are about to scan N files … uses your LLM budget" confirmation matters most. The most expensive case shows the least alarming number, weakening the secondary cost guard. Authorization/scoping/permission gates still fully protect the operation, so this is UX-quality, not a vulnerability. Suggest an indeterminate message ("all files in this library") when `HasCount` is false rather than "0 files". REVIEW VERDICT: 0 blocker, 0 major, 1 minor
Author
Owner

[MINOR] internal/books/metadata_store_test.go:709-710 — multiple Expect calls in single It block
This test has two Expect calls:

  • Line 709: Expect(handlerErr).To(HaveOccurred())
  • Line 710: Expect(handlerErr.Error()).To(ContainSubstring("encode"))

Project convention (project-conventions.md) requires exactly one Expect per It block. While there is precedent for this pattern in metadata_handler_test.go (ListProvidersHandler encode-error test), the convention should be followed. Fix: Split into two It blocks or combine into a single assertion. Note: This does not impact correctness or functionality, only test style consistency.


REVIEW VERDICT: 0 blocker, 0 major, 1 minor

[MINOR] internal/books/metadata_store_test.go:709-710 — multiple Expect calls in single It block This test has two Expect calls: - Line 709: `Expect(handlerErr).To(HaveOccurred())` - Line 710: `Expect(handlerErr.Error()).To(ContainSubstring("encode"))` Project convention (project-conventions.md) requires exactly one Expect per It block. While there is precedent for this pattern in metadata_handler_test.go (ListProvidersHandler encode-error test), the convention should be followed. Fix: Split into two It blocks or combine into a single assertion. Note: This does not impact correctness or functionality, only test style consistency. --- REVIEW VERDICT: 0 blocker, 0 major, 1 minor
Author
Owner

Security Review — PR #1245 (bd-bookshelf-zedtf)

Scope: metadata-refetch handler now returns HTTP 200 {"matched":false} (was 404) when providers find no match, plus a ?no_match=1 browser-redirect param. Checked authz preservation, injection/open-redirect, and existence-oracle leak.

Findings

No blockers, majors, or minors.

(1) Ownership/authz check preserved — PASS. RefetchMetadataHandler calls checkBookAccess(ctx, userIDFromRequest(r), id) and returns its error unchanged before refetch(...) runs. Only metadata.ErrNoMatch from the post-access-check refetch call is converted to 200; every non-ErrNoMatch error still propagates. A book the user cannot access still fails at checkBookAccess, which returns wrapped middleware.ErrNotFound (uniform 404) for all denial cases — nonexistent, unowned, content-restricted, unauthenticated (userID==0). The 200 no-match path is unreachable for an inaccessible book. (internal/books/metadata_handler.go RefetchMetadataHandler; internal/books/service.go:684 CheckBookAccess)

(2) No injection / open-redirect — PASS. The ?no_match=1 and ?cover_pending=1 params are static string literals. Redirect targets are fmt.Sprintf("/books/%d...", id) where id is an int64 produced by parseID (strconv.ParseInt, base-10) — non-numeric input is rejected as a validation error before any formatting, so %d cannot inject path/query/CRLF content. The redirect destination is a fixed relative path (no user-controlled URL) — no open-redirect. JSON body is static booleans only; no user-influenced content reflected.

(3) No new existence oracle — PASS. "Book exists but no provider match" (200) is only reachable after a successful checkBookAccess, i.e. only for books the requesting user can already access. "Book not accessible" still returns uniform 404. An attacker cannot use the 200-vs-404 split to distinguish existence of a book they lack access to — the access check gates the 200 path entirely.

Tests (Go handler specs + Vitest controller specs) cover both JSON and browser no-match paths and the matched=true path; the checkBookAccess denial paths remain covered by existing specs.

REVIEW VERDICT: 0 blocker, 0 major, 0 minor

## Security Review — PR #1245 (bd-bookshelf-zedtf) Scope: metadata-refetch handler now returns HTTP 200 `{"matched":false}` (was 404) when providers find no match, plus a `?no_match=1` browser-redirect param. Checked authz preservation, injection/open-redirect, and existence-oracle leak. ### Findings No blockers, majors, or minors. **(1) Ownership/authz check preserved — PASS.** `RefetchMetadataHandler` calls `checkBookAccess(ctx, userIDFromRequest(r), id)` and returns its error unchanged *before* `refetch(...)` runs. Only `metadata.ErrNoMatch` from the post-access-check `refetch` call is converted to 200; every non-`ErrNoMatch` error still propagates. A book the user cannot access still fails at `checkBookAccess`, which returns wrapped `middleware.ErrNotFound` (uniform 404) for all denial cases — nonexistent, unowned, content-restricted, unauthenticated (userID==0). The 200 no-match path is unreachable for an inaccessible book. (`internal/books/metadata_handler.go` RefetchMetadataHandler; `internal/books/service.go:684` CheckBookAccess) **(2) No injection / open-redirect — PASS.** The `?no_match=1` and `?cover_pending=1` params are static string literals. Redirect targets are `fmt.Sprintf("/books/%d...", id)` where `id` is an `int64` produced by `parseID` (`strconv.ParseInt`, base-10) — non-numeric input is rejected as a validation error before any formatting, so `%d` cannot inject path/query/CRLF content. The redirect destination is a fixed relative path (no user-controlled URL) — no open-redirect. JSON body is static booleans only; no user-influenced content reflected. **(3) No new existence oracle — PASS.** "Book exists but no provider match" (200) is only reachable *after* a successful `checkBookAccess`, i.e. only for books the requesting user can already access. "Book not accessible" still returns uniform 404. An attacker cannot use the 200-vs-404 split to distinguish existence of a book they lack access to — the access check gates the 200 path entirely. Tests (Go handler specs + Vitest controller specs) cover both JSON and browser no-match paths and the matched=true path; the `checkBookAccess` denial paths remain covered by existing specs. REVIEW VERDICT: 0 blocker, 0 major, 0 minor
zombor merged commit ef94e7f7f7 into main 2026-07-26 19:14:04 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
zombor/pergamum!1245
No description provided.