feat(ci): nightly load test + loadtest-ci target (bookshelf-hj9.3) #601

Merged
zombor merged 5 commits from bd-bookshelf-hj9.3 into main 2026-06-18 14:45:39 +00:00
Owner

Adds nightly load test workflow (.forgejo/workflows/loadtest.yml) + loadtest-ci Makefile target. Zero impact on normal PR pipeline — nightly only. Closes bead bookshelf-hj9.3 on merge.

Adds nightly load test workflow (.forgejo/workflows/loadtest.yml) + loadtest-ci Makefile target. Zero impact on normal PR pipeline — nightly only. Closes bead bookshelf-hj9.3 on merge.
feat(ci): nightly load test workflow + loadtest-ci Makefile target (bookshelf-hj9.3)
All checks were successful
/ JS Unit Tests (pull_request) Successful in 32s
/ Lint (pull_request) Successful in 1m2s
/ E2E API (pull_request) Successful in 1m25s
/ Integration (pull_request) Successful in 2m9s
/ Test (pull_request) Successful in 2m13s
/ E2E Browser (pull_request) Successful in 2m21s
7820cc8ed9
Wire the load test harness into CI as a NIGHTLY scheduled job (02:00 UTC) rather
than a per-PR required check. Rationale: 250k seed + two 30s k6 runs = ~5-8min,
which would triple the <2min PR wall-clock target (epic bookshelf-17t). Nightly
catches regressions from any day's merges; workflow_dispatch enables on-demand
runs before performance-sensitive merges.

New .forgejo/workflows/loadtest.yml:
- Seeds 250k books into an in-RAM MySQL service container
- Starts the app (bootstrap admin user via first-run init)
- Installs k6 static binary, runs books_list.js + book_detail.js with
  --summary-export JSON output
- Parses p99 values via Python, posts results table as Forgejo issue comment
  on bead bookshelf-hj9 using the built-in GITHUB_TOKEN (no extra secret)
- Fails the workflow job on k6 threshold violations (visible in nightly history)
- Supports workflow_dispatch with overrideable seed_count/vus/duration inputs

New Makefile loadtest-ci target:
- Like loadtest but adds --out json + --summary-export flags for machine-readable
  CI output; writes to LOADTEST_RESULTS_DIR (/tmp/k6-results by default)

Closes bead bookshelf-hj9.3 on merge.
Author
Owner

Code Review: bookshelf-hj9.3 (PR #601)

Phase 0: DEMO Verification

No DEMO block is required for a workflow-only PR (the "demo" is the nightly run itself). CI green confirmed per bead comment. Proceeding to spec compliance.

Phase 1: Spec Compliance

ci.yml diff is empty — confirmed via git diff main...origin/bd-bookshelf-hj9.3 -- .forgejo/workflows/ci.yml producing no output. The per-PR pipeline is fully unaffected.

Trigger gating: on: schedule (02:00 UTC) + workflow_dispatch only. No push or pull_request triggers. Heavy job is correctly isolated from per-PR CI.

Thresholds match the Scale targets from project-conventions.md (GET /books p99 < 100ms, GET /books/{id} p99 < 30ms). k6 exit code propagation causes the nightly job itself to fail on violations — visible, not silently swallowed.


Phase 2: Code Quality

[MAJOR] .forgejo/workflows/loadtest.yml:183-218 — set -e aborts k6 step on first threshold failure; second script never runs

The "Run k6 load tests" step opens with set -e. The two k6 invocations are:

k6 run ... loadtest/books_list.js \
; BOOKS_LIST_EXIT=$?

Under set -e, if books_list.js exits non-zero (threshold violation), the shell exits immediately after the k6 process — the ; does not protect the assignment. BOOKS_LIST_EXIT is never set, and the entire step aborts, so book_detail.js never runs. The reporting step (with if: always()) still fires, but only one test was measured. On a books_list regression the report shows GET /books = FAIL (via the :-1 default) and GET /books/{id} = FAIL (also :-1), even though the detail endpoint may actually be healthy.

Fix: capture each exit code without relying on set -e abort semantics:

k6 run ... loadtest/books_list.js || true
BOOKS_LIST_EXIT=$?

Or run both with explicit || true and capture $? after each, keeping set -e for other commands.

[MAJOR] .forgejo/workflows/loadtest.yml:276-284 — set -e + unchecked curl POST can fail the job for non-performance reasons

The "Parse results and post bead comment" step also runs under set -e. The curl -sk -X POST ... > /dev/null at line ~276 does not have || true. If the Forgejo API returns a non-2xx status or a network error causes curl to exit non-zero, set -e fires and the step exits before reaching the intended exit $EXIT_CODE. The workflow job then fails with a misleading non-zero status unrelated to load-test results. A transient API blip makes it look like a performance regression.

Fix: append || echo "Warning: failed to post comment" to the curl POST line so network failures are logged but do not abort the step.

[MINOR] .forgejo/workflows/loadtest.yml:248-261 — issue search is fragile (state=open, limit=50)

The ISSUE_NUM lookup fetches state=open&limit=50 and matches on title containing "hj9" or "load test". If the hj9 epic bead is ever closed (status=done) before the nightly job is retired, the search returns no match and results only print to the workflow log — silently losing the comment history. Also, limit=50 could miss the bead if there are many open issues and the bead sorts beyond position 50.

Fix (minor): add state=all instead of state=open, and increase limit or paginate. Or hardcode the known issue number once resolved.

[MINOR] .forgejo/workflows/loadtest.yml:107-115 — k6 downloaded from github.com with no checksum verification

The k6 binary is fetched from github.com/grafana/k6/releases/download/v0.54.0/... with no SHA-256 checksum check. A supply-chain compromise or GitHub outage silently gives a bad binary or fails the job. For a nightly job this is low severity but worth noting.

Fix: add a sha256sum --check step after the download using the known checksum from the k6 release page.

[MINOR] Makefile:179-192 — loadtest-ci first script abort prevents second from running

The loadtest-ci target uses @echo ... && k6 run ... loadtest/books_list.js for the first script. If the first k6 run exits non-zero, && short-circuits and Make aborts the recipe. The second book_detail.js run is skipped. This only affects local make loadtest-ci invocations (the workflow calls k6 directly), but it is still inconsistent with the expected behavior.

Fix: run each k6 invocation as its own recipe line without &&, capturing exit codes explicitly, then exit non-zero at the end if either failed.


Summary

Finding Severity Location
set -e aborts k6 step; second script skipped on first failure MAJOR loadtest.yml:183-218
curl POST without || true; API blip fails job with wrong exit reason MAJOR loadtest.yml:276-284
Issue search: state=open + limit=50 loses comments if bead closes MINOR loadtest.yml:248-261
k6 binary from github.com with no checksum MINOR loadtest.yml:107-115
loadtest-ci Make target: first-script abort skips second MINOR Makefile:179-192

REVIEW VERDICT: 0 blocker, 2 major, 3 minor

## Code Review: bookshelf-hj9.3 (PR #601) ### Phase 0: DEMO Verification No DEMO block is required for a workflow-only PR (the "demo" is the nightly run itself). CI green confirmed per bead comment. Proceeding to spec compliance. ### Phase 1: Spec Compliance ci.yml diff is empty — confirmed via `git diff main...origin/bd-bookshelf-hj9.3 -- .forgejo/workflows/ci.yml` producing no output. The per-PR pipeline is fully unaffected. Trigger gating: `on: schedule` (02:00 UTC) + `workflow_dispatch` only. No `push` or `pull_request` triggers. Heavy job is correctly isolated from per-PR CI. Thresholds match the Scale targets from project-conventions.md (`GET /books` p99 < 100ms, `GET /books/{id}` p99 < 30ms). k6 exit code propagation causes the nightly job itself to fail on violations — visible, not silently swallowed. --- ### Phase 2: Code Quality **[MAJOR] .forgejo/workflows/loadtest.yml:183-218 — `set -e` aborts k6 step on first threshold failure; second script never runs** The "Run k6 load tests" step opens with `set -e`. The two k6 invocations are: ``` k6 run ... loadtest/books_list.js \ ; BOOKS_LIST_EXIT=$? ``` Under `set -e`, if `books_list.js` exits non-zero (threshold violation), the shell exits immediately after the k6 process — the `;` does not protect the assignment. `BOOKS_LIST_EXIT` is never set, and the entire step aborts, so `book_detail.js` never runs. The reporting step (with `if: always()`) still fires, but only one test was measured. On a `books_list` regression the report shows `GET /books` = FAIL (via the `:-1` default) and `GET /books/{id}` = FAIL (also `:-1`), even though the detail endpoint may actually be healthy. Fix: capture each exit code without relying on `set -e` abort semantics: ```bash k6 run ... loadtest/books_list.js || true BOOKS_LIST_EXIT=$? ``` Or run both with explicit `|| true` and capture `$?` after each, keeping `set -e` for other commands. **[MAJOR] .forgejo/workflows/loadtest.yml:276-284 — `set -e` + unchecked `curl POST` can fail the job for non-performance reasons** The "Parse results and post bead comment" step also runs under `set -e`. The `curl -sk -X POST ... > /dev/null` at line ~276 does not have `|| true`. If the Forgejo API returns a non-2xx status or a network error causes curl to exit non-zero, `set -e` fires and the step exits before reaching the intended `exit $EXIT_CODE`. The workflow job then fails with a misleading non-zero status unrelated to load-test results. A transient API blip makes it look like a performance regression. Fix: append `|| echo "Warning: failed to post comment"` to the curl POST line so network failures are logged but do not abort the step. **[MINOR] .forgejo/workflows/loadtest.yml:248-261 — issue search is fragile (state=open, limit=50)** The ISSUE_NUM lookup fetches `state=open&limit=50` and matches on title containing "hj9" or "load test". If the hj9 epic bead is ever closed (status=done) before the nightly job is retired, the search returns no match and results only print to the workflow log — silently losing the comment history. Also, limit=50 could miss the bead if there are many open issues and the bead sorts beyond position 50. Fix (minor): add `state=all` instead of `state=open`, and increase `limit` or paginate. Or hardcode the known issue number once resolved. **[MINOR] .forgejo/workflows/loadtest.yml:107-115 — k6 downloaded from github.com with no checksum verification** The k6 binary is fetched from `github.com/grafana/k6/releases/download/v0.54.0/...` with no SHA-256 checksum check. A supply-chain compromise or GitHub outage silently gives a bad binary or fails the job. For a nightly job this is low severity but worth noting. Fix: add a `sha256sum --check` step after the download using the known checksum from the k6 release page. **[MINOR] Makefile:179-192 — `loadtest-ci` first script abort prevents second from running** The `loadtest-ci` target uses `@echo ... && k6 run ... loadtest/books_list.js` for the first script. If the first k6 run exits non-zero, `&&` short-circuits and Make aborts the recipe. The second `book_detail.js` run is skipped. This only affects local `make loadtest-ci` invocations (the workflow calls k6 directly), but it is still inconsistent with the expected behavior. Fix: run each k6 invocation as its own recipe line without `&&`, capturing exit codes explicitly, then exit non-zero at the end if either failed. --- ### Summary | Finding | Severity | Location | |---|---|---| | `set -e` aborts k6 step; second script skipped on first failure | MAJOR | loadtest.yml:183-218 | | `curl POST` without `\|\| true`; API blip fails job with wrong exit reason | MAJOR | loadtest.yml:276-284 | | Issue search: state=open + limit=50 loses comments if bead closes | MINOR | loadtest.yml:248-261 | | k6 binary from github.com with no checksum | MINOR | loadtest.yml:107-115 | | `loadtest-ci` Make target: first-script abort skips second | MINOR | Makefile:179-192 | REVIEW VERDICT: 0 blocker, 2 major, 3 minor
Author
Owner

Security Review — PR #601 (bd-bookshelf-hj9.3)

Scope: .forgejo/workflows/loadtest.yml + Makefile loadtest-ci target. CI/infra only, no production code.


Findings

No security issues found.

Token handlingFORGEJO_TOKEN is ${{ secrets.GITHUB_TOKEN }}, the built-in least-privilege Forgejo token. It is never echoed into logs (curl -sk … > /dev/null). REGISTRY_TOKEN is a pull-only registry credential used only for container image auth. Neither secret is printed or stored anywhere reachable.

Workflow injectionworkflow_dispatch inputs (seed_count, vus, duration) are assigned to job-level env vars (SEED_COUNT, LOADTEST_VUS, LOADTEST_DURATION) via the expression layer and then used as double-quoted shell variables ("${LOADTEST_DURATION}"). They are never interpolated directly into a run: script via ${{ … }}, so there is no shell-injection vector. They also appear in the markdown $BODY string, which is piped through python3 … json.dumps before being sent to the API — any injected content becomes a literal string in the JSON body, not executable.

REF_NAME (github.ref_name) ends up in $BODY as well; since workflow_dispatch is restricted to users with write access on a private repo, a branch-name injection would require repo write access — already trusted. No pull_request / pull_request_target trigger exists.

Trigger scopeschedule + workflow_dispatch only. No pull_request trigger (which would allow untrusted forks to run with elevated permissions). workflow_dispatch requires repo write access in Forgejo. No elevation of privilege path.

App binding — the app starts on :8080 inside the runner container (not a cloud VM with a public IP); Forgejo Actions runners are isolated containers. The password (Loadtest_P4ssword!) is a hardcoded ephemeral CI credential scoped to this throwaway DB — not a production secret. No credentials are bound to external-facing surfaces.

ISSUE_NUM URL construction — derived from i["number"] in Python, which is an integer parsed from the Forgejo API JSON response (trusted source). No user-controlled string is interpolated into the curl URL path.

k6 binary — downloaded from github.com/grafana/k6/releases at a pinned version (v0.54.0); no latest alias. No SHA256 pin, but this is a minor hardening gap (not a blocking finding for an internal CI tool with no production-code impact).


REVIEW VERDICT: 0 blocker, 0 major, 0 minor

## Security Review — PR #601 (bd-bookshelf-hj9.3) **Scope:** `.forgejo/workflows/loadtest.yml` + `Makefile` `loadtest-ci` target. CI/infra only, no production code. --- ### Findings No security issues found. **Token handling** — `FORGEJO_TOKEN` is `${{ secrets.GITHUB_TOKEN }}`, the built-in least-privilege Forgejo token. It is never echoed into logs (`curl -sk … > /dev/null`). `REGISTRY_TOKEN` is a pull-only registry credential used only for container image auth. Neither secret is printed or stored anywhere reachable. **Workflow injection** — `workflow_dispatch` inputs (`seed_count`, `vus`, `duration`) are assigned to job-level env vars (`SEED_COUNT`, `LOADTEST_VUS`, `LOADTEST_DURATION`) via the expression layer and then used as **double-quoted** shell variables (`"${LOADTEST_DURATION}"`). They are never interpolated directly into a `run:` script via `${{ … }}`, so there is no shell-injection vector. They also appear in the markdown `$BODY` string, which is piped through `python3 … json.dumps` before being sent to the API — any injected content becomes a literal string in the JSON body, not executable. `REF_NAME` (`github.ref_name`) ends up in `$BODY` as well; since `workflow_dispatch` is restricted to users with write access on a private repo, a branch-name injection would require repo write access — already trusted. No `pull_request` / `pull_request_target` trigger exists. **Trigger scope** — `schedule` + `workflow_dispatch` only. No `pull_request` trigger (which would allow untrusted forks to run with elevated permissions). `workflow_dispatch` requires repo write access in Forgejo. No elevation of privilege path. **App binding** — the app starts on `:8080` inside the runner container (not a cloud VM with a public IP); Forgejo Actions runners are isolated containers. The password (`Loadtest_P4ssword!`) is a hardcoded ephemeral CI credential scoped to this throwaway DB — not a production secret. No credentials are bound to external-facing surfaces. **ISSUE_NUM URL construction** — derived from `i["number"]` in Python, which is an integer parsed from the Forgejo API JSON response (trusted source). No user-controlled string is interpolated into the curl URL path. **k6 binary** — downloaded from `github.com/grafana/k6/releases` at a pinned version (`v0.54.0`); no `latest` alias. No SHA256 pin, but this is a minor hardening gap (not a blocking finding for an internal CI tool with no production-code impact). --- REVIEW VERDICT: 0 blocker, 0 major, 0 minor
fix(ci): address review findings on loadtest workflow + Makefile (bookshelf-hj9.3)
All checks were successful
/ JS Unit Tests (pull_request) Successful in 29s
/ E2E API (pull_request) Successful in 1m25s
/ Lint (pull_request) Successful in 2m2s
/ Integration (pull_request) Successful in 2m21s
/ Test (pull_request) Successful in 2m24s
/ E2E Browser (pull_request) Successful in 2m17s
02df05f1aa
- Both k6 runs use || true before ; $? capture so set -e does not abort
  on a threshold failure; both scripts always execute, exit codes captured
  and propagated via final exit $EXIT_CODE
- curl POST to post bead comment now has || echo "Warning: ..." so a
  non-2xx/network error does not fail the job before exit $EXIT_CODE runs
- Issue search uses state=all&limit=200 so closed/paged-out beads resolve
- k6 v0.54.0 linux-amd64 tarball verified via sha256sum after download
- Makefile loadtest-ci target runs both k6 scripts unconditionally (; not
  &&), captures individual exit codes, and exits non-zero if either fails

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

Code Re-Review: bookshelf-hj9.3 (PR #601) — Fix Verification

Per-finding verification against fix commit 02df05f1

[MAJOR #1] .forgejo/workflows/loadtest.yml:183-218set -e aborts k6 step on first threshold failure

NOT RESOLVED — new bug introduced.

The fix uses k6 run ... || true; BOOKS_LIST_EXIT=$? (loadtest.yml:205) and the identical pattern for BOOK_DETAIL_EXIT (line 218). However, || true makes the compound command always exit 0, so $? is always 0 regardless of k6's actual exit code. Verified via shell semantics: bash -c 'false || true; E=$?; echo $E' prints 0.

Effect: threshold violations are silently swallowed. BOOKS_LIST_EXIT and BOOK_DETAIL_EXIT are always 0, the reporting step always shows PASS, and exit $EXIT_CODE at the end is always a no-op. The nightly job will never go red on a real performance regression.

The same defective pattern appears in the Makefile loadtest-ci target (lines || true ; \ BOOKS_LIST_EXIT=$$?). $$? is always 0 there too.

Correct fix patterns:

Option A — set +e / set -e bracket:

set +e
k6 run ... loadtest/books_list.js
BOOKS_LIST_EXIT=$?
k6 run ... loadtest/book_detail.js
BOOK_DETAIL_EXIT=$?
set -e

Option B — inline without set -e:

k6 run ... loadtest/books_list.js && BOOKS_LIST_EXIT=0 || BOOKS_LIST_EXIT=$?
k6 run ... loadtest/book_detail.js && BOOK_DETAIL_EXIT=0 || BOOK_DETAIL_EXIT=$?

[MAJOR #2] .forgejo/workflows/loadtest.yml:276-284curl POST without || true fails job for non-perf reasons

RESOLVED. Line 336 now has || echo "Warning: failed to post loadtest comment".


[MINOR #3] .forgejo/workflows/loadtest.yml:248-261 — issue search state=open + limit=50

RESOLVED. Line 319 uses state=all&limit=200.


[MINOR #4] .forgejo/workflows/loadtest.yml:107-115 — k6 download with no checksum

RESOLVED. Lines 121-123 hardcode K6_SHA256 and pipe to sha256sum --check.


[MINOR #5] Makefile:179-192loadtest-ci && short-circuit skips second script

PARTIALLY RESOLVED. The && is gone — both scripts always run. However, the same || true; BOOKS_LIST_EXIT=$$? bug from Major #1 applies here too: both exit codes are always 0, so exit $$EXIT is always a no-op. Both scripts run (the specific minor finding is fixed), but exit code propagation is broken.


ci.yml unchanged: confirmed — git diff origin/main...origin/bd-bookshelf-hj9.3 -- .forgejo/workflows/ci.yml is empty.

No new issues other than the broken exit-code capture noted above.


Verdict

REVIEW VERDICT: 0 blocker, 1 major, 0 minor

The exit-code capture regression (|| true; VAR=$? always yields VAR=0) means threshold violations are permanently silenced — the nightly job's core purpose (alerting on regressions) is non-functional. This is a [MAJOR] that must be fixed before merge.

## Code Re-Review: bookshelf-hj9.3 (PR #601) — Fix Verification ### Per-finding verification against fix commit `02df05f1` **[MAJOR #1] `.forgejo/workflows/loadtest.yml:183-218` — `set -e` aborts k6 step on first threshold failure** NOT RESOLVED — new bug introduced. The fix uses `k6 run ... || true; BOOKS_LIST_EXIT=$?` (loadtest.yml:205) and the identical pattern for `BOOK_DETAIL_EXIT` (line 218). However, `|| true` makes the compound command always exit 0, so `$?` is always 0 regardless of k6's actual exit code. Verified via shell semantics: `bash -c 'false || true; E=$?; echo $E'` prints `0`. Effect: threshold violations are silently swallowed. `BOOKS_LIST_EXIT` and `BOOK_DETAIL_EXIT` are always 0, the reporting step always shows PASS, and `exit $EXIT_CODE` at the end is always a no-op. The nightly job will never go red on a real performance regression. The same defective pattern appears in the Makefile `loadtest-ci` target (lines `|| true ; \ BOOKS_LIST_EXIT=$$?`). `$$?` is always 0 there too. Correct fix patterns: Option A — `set +e` / `set -e` bracket: ```bash set +e k6 run ... loadtest/books_list.js BOOKS_LIST_EXIT=$? k6 run ... loadtest/book_detail.js BOOK_DETAIL_EXIT=$? set -e ``` Option B — inline without `set -e`: ```bash k6 run ... loadtest/books_list.js && BOOKS_LIST_EXIT=0 || BOOKS_LIST_EXIT=$? k6 run ... loadtest/book_detail.js && BOOK_DETAIL_EXIT=0 || BOOK_DETAIL_EXIT=$? ``` --- **[MAJOR #2] `.forgejo/workflows/loadtest.yml:276-284` — `curl POST` without `|| true` fails job for non-perf reasons** RESOLVED. Line 336 now has `|| echo "Warning: failed to post loadtest comment"`. --- **[MINOR #3] `.forgejo/workflows/loadtest.yml:248-261` — issue search `state=open` + `limit=50`** RESOLVED. Line 319 uses `state=all&limit=200`. --- **[MINOR #4] `.forgejo/workflows/loadtest.yml:107-115` — k6 download with no checksum** RESOLVED. Lines 121-123 hardcode `K6_SHA256` and pipe to `sha256sum --check`. --- **[MINOR #5] `Makefile:179-192` — `loadtest-ci` `&&` short-circuit skips second script** PARTIALLY RESOLVED. The `&&` is gone — both scripts always run. However, the same `|| true; BOOKS_LIST_EXIT=$$?` bug from Major #1 applies here too: both exit codes are always 0, so `exit $$EXIT` is always a no-op. Both scripts run (the specific minor finding is fixed), but exit code propagation is broken. --- **ci.yml unchanged**: confirmed — `git diff origin/main...origin/bd-bookshelf-hj9.3 -- .forgejo/workflows/ci.yml` is empty. **No new issues** other than the broken exit-code capture noted above. --- ### Verdict REVIEW VERDICT: 0 blocker, 1 major, 0 minor The exit-code capture regression (`|| true; VAR=$?` always yields VAR=0) means threshold violations are permanently silenced — the nightly job's core purpose (alerting on regressions) is non-functional. This is a [MAJOR] that must be fixed before merge.
fix(ci): replace broken '|| true; $?' with set +e/set -e bracket (bookshelf-hj9.3)
All checks were successful
/ JS Unit Tests (pull_request) Successful in 17s
/ Lint (pull_request) Successful in 1m51s
/ E2E API (pull_request) Successful in 1m53s
/ Integration (pull_request) Successful in 2m39s
/ Test (pull_request) Successful in 2m41s
/ E2E Browser (pull_request) Successful in 2m45s
8319682c0c
'|| true; EXIT=$?' is always 0 because || true is the last command before $?.
Replace with set +e before both k6 runs, capture $? immediately after each,
then set -e — so a real threshold breach propagates to EXIT_CODE and fails
the nightly job. Same fix applied to the Makefile loadtest-ci target.

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

CODE REVIEW: APPROVED

Phase 0: DEMO Verification

No new DEMO block required for this targeted re-fix. Prior review (comment 7113) verified DEMO output; this re-review is scoped to the single exit-capture regression fix.

Phase 1: Exit-capture fix — .forgejo/workflows/loadtest.yml (lines 195-220)

  • set +e at line 195, BEFORE the first k6 invocation.
  • BOOKS_LIST_EXIT=$? captured immediately at line 206, directly after the first k6 run. NO || true in between.
  • Second k6 run (lines 209-218) likewise has no || true.
  • BOOK_DETAIL_EXIT=$? captured immediately at line 219.
  • set -e restored at line 220, right after both captures.
  • Both exit codes rolled up at lines 353-355; exit $EXIT_CODE propagates failure correctly.

The prior MAJOR (exit always 0 due to || true; EXIT=$?) is fully resolved.

Phase 2: Makefile loadtest-ci target (lines 176-204)

  • Single shell block with set +e at the top.
  • BOOKS_LIST_EXIT=$$? captured immediately after the first k6 run, no || true.
  • BOOK_DETAIL_EXIT=$$? captured immediately after the second k6 run, no || true.
  • set -e restored, then rollup into EXIT and exit $$EXIT. Correct.

Phase 3: Previously-resolved findings still intact

  • curl POST || echo warning: line 338, intact.
  • state=all issue search: line 321, intact.
  • ci.yml: not modified on this branch.

No new issues found.

REVIEW VERDICT: 0 blocker, 0 major, 0 minor

CODE REVIEW: APPROVED ## Phase 0: DEMO Verification No new DEMO block required for this targeted re-fix. Prior review (comment 7113) verified DEMO output; this re-review is scoped to the single exit-capture regression fix. ## Phase 1: Exit-capture fix — .forgejo/workflows/loadtest.yml (lines 195-220) - `set +e` at line 195, BEFORE the first k6 invocation. - `BOOKS_LIST_EXIT=$?` captured immediately at line 206, directly after the first k6 run. NO `|| true` in between. - Second k6 run (lines 209-218) likewise has no `|| true`. - `BOOK_DETAIL_EXIT=$?` captured immediately at line 219. - `set -e` restored at line 220, right after both captures. - Both exit codes rolled up at lines 353-355; `exit $EXIT_CODE` propagates failure correctly. The prior MAJOR (exit always 0 due to `|| true; EXIT=$?`) is fully resolved. ## Phase 2: Makefile loadtest-ci target (lines 176-204) - Single shell block with `set +e` at the top. - `BOOKS_LIST_EXIT=$$?` captured immediately after the first k6 run, no `|| true`. - `BOOK_DETAIL_EXIT=$$?` captured immediately after the second k6 run, no `|| true`. - `set -e` restored, then rollup into `EXIT` and `exit $$EXIT`. Correct. ## Phase 3: Previously-resolved findings still intact - curl POST `|| echo` warning: line 338, intact. - `state=all` issue search: line 321, intact. - ci.yml: not modified on this branch. ## No new issues found. REVIEW VERDICT: 0 blocker, 0 major, 0 minor
fix(loadtest): correct Makefile DSN to pergamum + seeder ensures library exists (bookshelf-hj9.3)
All checks were successful
/ JS Unit Tests (pull_request) Successful in 42s
/ Lint (pull_request) Successful in 50s
/ E2E API (pull_request) Successful in 1m29s
/ Integration (pull_request) Successful in 2m15s
/ Test (pull_request) Successful in 2m18s
/ E2E Browser (pull_request) Successful in 2m23s
7feb80185b
Bug 1: Makefile DSN fallback used stale bookshelf:bookshelf creds after the
bookshelf→pergamum rename. Fix: update default to pergamum:pergamum.

Bug 2: seeder required --library-id pointing at an existing library, causing
FK failures on a fresh migrated DB (no library row exists). Fix: add EnsureLibrary
to internal/seeder that looks up or creates a library by name; update seed.go to
add --library-name flag (default "Seed Library") that calls EnsureLibrary when
--library-id is 0; update make seed target to pass --library-name so a fresh
DB Just Works with no manual setup. Integration tests added for EnsureLibrary.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace all hardcoded stubUserID=1 in shelves/handler.go and
shelves/magic/handler.go with real userID from JWT session claims
(ExtractUser). Add UserLibraryIDs field to EvalOptions so the magic-shelf
evaluator ANDs a library restriction into the compiled WHERE clause —
fail-closed: empty slice → 1=0, nil → no restriction (mirrors books.ListHandler).

Thread userLibraryIDs through the HTML path filter in both static-shelf and
magic-shelf show handlers so books are scoped to the authenticated user's
accessible libraries. Update wiring in both wire.go files to bind
GetUserLibraryIDs from appwire.Deps.

Tests: evaluator unit tests for nil/empty/non-empty UserLibraryIDs,
handler tests for library-IDs propagation, error-path coverage, real
userID from ExtractUser flowing to Create/ownership checks. Coverage 100%.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add a Bulk Edit mode to the BookDrop review page that lets users edit one or
more metadata fields (title, subtitle, author, series, publisher, etc.) across
all selected proposals at once before finalizing import.

- POST /bookdrop/bulk-edit: BulkEditMetadataHandler applies a fileMetadataJSON
  patch to every selected proposal's original_metadata serially, returning
  applied/failed counts
- BulkEditMetadata service function: curried composition over UpdateProposalMetadata
- bookdrop-bulk-edit Stimulus controller: CSP-safe modal (no inline style=),
  toolbar button enabled only when ≥1 row selected, author chip management,
  page reloads with ?bulk_edit_applied= flash on success
- Flash validation regex prevents XSS via URL param (mirrors extract-pattern)
- bbe-* CSS classes for the 2-column grid modal
- go-rod Ordered browser e2e: seeds 2 proposals, selects all, opens modal,
  fills publisher, applies, verifies DB + flash + uploads screenshots to PR
- 100% coverage maintained (+11 specs, 551 total)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Split 2-Expect It in review_handler_test (HaveLen + title) into two Its
- Split 3-Expect It in review_service_test (ids[0/1/2]) into three Its
- Split 2-Expect It in review_service_test (remaining errors) into two Its
- Add "trace_id" attr to BulkEditMetadataHandler logger.Info (matches other handlers)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace bespoke .bbe-* and .bep-* chrome (header/footer/close btn/labels/inputs)
with canonical .sa-modal-header/.sa-modal-title/.sa-modal-close-btn/.sa-modal-footer
and .metadata-field/.metadata-field-label/.metadata-field-input. Delete the 130
dead CSS lines. Keep .bbe-form grid for two-column layout, .bep-* for the
dynamically-generated preview area HTML (controller JS emits those classes), and
bep-subhead/section/chips for extract-pattern content structure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Config (ff): OIDC enable flag, issuer, client_id, client_secret,
  redirect_url, groups_claim, scopes with validation
- Routes: GET /auth/oidc/login (CSRF state cookie + redirect) and
  GET /auth/oidc/callback (code exchange, ID token JWKS verification)
- User provisioning: creates users row on first login (provisioning_method='oidc'),
  updates name/email on subsequent logins
- Group→role mapping: reads oidc_group_mapping table, unions matching rows,
  syncs onto user_permissions + user_library_mapping on every login
- JWT session issuance reuses existing local-login infrastructure
- Login page shows "Sign in with SSO" button when OIDC is enabled
- 100% unit test coverage; OIDC provider mocked via httptest.Server

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- oidc_coverage_test.go: comprehensive tests for verifyIDToken/jwkToRSA,
  provisionOIDCUser, claimString, RegisterOIDCRoutes, OIDCHandleCallback
  error paths, syncOIDCGroupMappings error paths, body-read errors,
  and all applyPermissionsJSON permission fields
- export_test.go: add test exports for verifyIDToken, jwkToRSA,
  provisionOIDCUser, claimString, SetOIDCHTTPClient, RegisterOIDCRoutes,
  OIDCHandleCallback
- oidc_service.go: use shared randRead var in generateOIDCState so
  rand error path is testable; remove unused crypto/rand import
- oidc_jwks.go: restore default aud case to catch missing aud (nil)
  from JWT tokens, making it reachable and testable

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MAJOR 1 — group-sync revoke + fatal:
- syncOIDCGroupMappings is now authoritative (replace, not additive): when the
  user has no groups or no groups match any mapping, an explicit zero-permissions
  upsert + empty library-set is issued so stale admin/perms are revoked on every
  login. Previously an empty-groups early return silently preserved stale perms.
- Group-sync failure is now fatal in OIDCHandleCallback: login is blocked rather
  than logging the user in with unsynced permissions.

MAJOR 2 — HTTPS issuer enforcement:
- config.Validate() now rejects any OIDCIssuer that does not start with "https://"
  (except http://localhost for dev). An http:// issuer allows MITM of the
  discovery fetch and jwks_uri substitution → ID token forgery.

MAJOR 3 — discovery issuer validation:
- DiscoverOIDCMeta now compares the parsed discovery doc's "issuer" field against
  the configured issuer (RFC 8414 §3.3 / OIDC Discovery §4.3). A mismatch
  indicates a substituted document and is rejected.

MINOR — ES256 (EC key) support:
- verifyIDToken and the key-building loop now support EC keys (P-256/P-384/P-521)
  alongside RSA. New jwkToEC helper validates the point via crypto/ecdh (the
  non-deprecated API). Providers using ES256 (Keycloak, Dex) now work correctly.

MINOR — safe sub assertion:
- Replaced bare claims["sub"].(string) with claimString(claims, "sub") to avoid
  panic on a malformed token where the sub claim is not a string.

Tests: added/updated to cover all 5 fixes; all 455+ specs pass green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
All 5 blocks reported by check-coverage.sh are now exercised:
  oidc_jwks.go:120 — RSA key with empty N field → continue
  oidc_jwks.go:133 — EC key with bad coords → jwkToEC error → continue
  oidc_jwks.go:228 — P-384 curve branch in jwkToEC
  oidc_jwks.go:231 — P-521 curve branch in jwkToEC
  oidc_service.go:409 — setLibraries error in !anyMatched revocation path

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1. config.go: Replace string-prefix carve-out for localhost with URL-parsing
   isLoopbackHTTP helper.  url.Parse+Hostname() correctly rejects authority
   confusables (http://localhost:8080@evil.com → host=evil.com, not loopback;
   http://localhost.evil.com → host=localhost.evil.com).  Regression tests
   added for all four attack vectors from the review finding, plus the six
   accepted cases (localhost, 127.0.0.1, localhost with port/path,
   https://accounts.google.com).

2. oidc_service.go: Drop the `len(libIDs) > 0` guard so setLibraries is
   called whenever anyMatched==true, even when the matching mapping has only
   IsAdmin/perms and no library_ids.  Stale library memberships from a prior
   login are now revoked in that case.  Regression test added.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add test case for unparseable issuer URL to exercise the err != nil branch
at config.go:468. Test passes http://[::1 (unterminated IPv6 bracket) which
causes url.Parse to error, triggering the missing error handler and resulting
in the expected "must be an HTTPS URL" validation failure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a 'Move files to target directory' checkbox to each duplicate group
in the Find Duplicates modal. When checked, physically relocates source
book files to the target book's library directory via os.Rename after the
DB merge completes.

- DISK_TYPE=NETWORK guard: pre-checked before DB merge → HTTP 409
- Path containment guards: null bytes, source-outside-library-root
- Best-effort: rename failure is Warn-logged, never blocks the merge
- CSP-compliant: no inline style= attributes (CSS classes only)
- 100% coverage on internal/dedup
- Browser e2e test: checkbox renders + toggle verified

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add MoveFilesAfterMerge service: renames each source book file into
  the target book's library directory; best-effort (rename failure is
  logged at Warn, never returned as an error to the caller)
- DISK_TYPE=NETWORK guard returns ErrNetworkDisk → HTTP 409 before the
  DB merge runs, and after if somehow triggered post-merge
- Null byte + path traversal guards; dst-already-exists guard via os.Stat
- Add ListSourceBookFiles / GetTargetBookLibraryRoot store functions
- Wire moveFiles into MergeHandler and ScanHandler (NetworkDisk flag)
- Restore full multi-criteria scan (external_id, same_directory, filename)
  + KeepStrategy from main; re-sync with PR #600 e2e pyramid refactor
- Add move_files checkbox to dedup_results.html template; update
  find_duplicates_controller.js to include move_files in merge POST body
- Add journey browser e2e step: checkbox unchecked by default, toggles
- 100% internal/ coverage maintained

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add two new test cases for moveOneFile that exercise genuine cross-directory
moves using real temp directories and the actual os.Rename function. The
existing "happy path" test was exercising only the "destination stat fails"
branch (both src and dst were in the same /library root, which doesn't exist
on test host), so it wasn't testing a real cross-directory move.

New tests:
- Assert the file genuinely moves from source to target directory
- Assert renamedSrc and renamedDst point to different library roots
- Use os.Stat to verify destination exists after the move
- Verify source file is deleted from source directory

Fixes the code-review minor from PR #606.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Four workflow-tester specs stubbed activities (ListEbooksForLLMSweep,
ListComicsForLLMSweep, FetchComicVineCandidates, GetEnabledProvidersForLLMSweep)
with plain errors.  Those activities run under bulkLLMActivityOptions /
llmSweepPerBookActivityOptions (MaxAttempts=3, FirstRetryInterval=10s).  Under
CI load (ginkgo -p 8 --randomize-all) the go-workflows tester schedules
retry-backoff timers that never fire, triggering the "workflow blocked" panic.

Same root cause and same fix as fb529d9b: wrap the stub errors in
gowf.NewPermanentError so the tester fails the activity immediately without
scheduling any timer.  All four sites now carry an explanatory comment.

Verified: 30 × ginkgo --randomize-all -p --race ./internal/wfengine/... with
zero panics.

Closes bead bookshelf-lt2p on merge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- CheckBookAccess now returns ErrForbidden (403) for content-restricted
  books, distinct from ErrNotFound (404) for library-access failures.
  Per-spec: 403 content-blocked vs 404 not-in-library.
- GetAdminUserContentRestrictions: fetch user_content_restriction rows
  for display in the admin user-edit form.
- SetAdminUserContentRestrictions: atomically replace all restrictions
  in a transaction (delete + insert).
- Admin user form template updated with a content-restrictions table:
  parallel arrays restriction_type[]/restriction_mode[]/restriction_value[]
  submitted on POST /admin/users/{id}. JS <template> for add-row.
  No inline style= (CSP-compliant).
- contentRestrictionsFromForm parses the parallel-array form fields,
  skipping invalid type/mode/empty-value rows.
- Wire in users.Wire() using d.Conn for the transaction adapter.
- 100% coverage gate passes; all existing tests updated.

Closes bead bookshelf-4op.10 on merge.
- Replace inline <script> + onclick= handlers with a new Stimulus controller
  (content_restriction_controller.js) that binds addRow/removeRow via data-action;
  the old pattern was silently blocked by the production script-src 'self' CSP
- Register controller in app.js and base.html via the existing <script defer> mechanism
- Add AGE_RATING numeric validation in contentRestrictionsFromForm so non-numeric
  values are rejected at save time rather than silently ignored at query time
- Split the two-Expect "calls delete but inserts nothing" It into two single-Expect Its
- Add Vitest unit tests for ContentRestrictionController (addRow clones row, removeRow
  removes row, cloned row has correct fields)

Fixes bookshelf-4op.10 BLOCKER + both MINORs per review comments 7108/7110.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(seeder): grant seed library access to all users so loadtest gets 200 not 404
All checks were successful
/ JS Unit Tests (pull_request) Successful in 29s
/ Lint (pull_request) Successful in 1m9s
/ E2E API (pull_request) Successful in 1m14s
/ Integration (pull_request) Successful in 2m1s
/ E2E Browser (pull_request) Successful in 2m2s
/ Test (pull_request) Successful in 2m8s
5df5a28eb6
Add GrantLibraryToAllUsers which does INSERT IGNORE INTO user_library_mapping
SELECT id, ? FROM users — idempotent, scoped to the seeded library only, mirrors
migration 0027. Call it from execSeed after EnsureLibrary so every seed run
(including re-runs on already-seeded DBs) ensures the requesting user can reach
the seeded books via CheckBookAccess.

Cover with three integration specs: mapping created for all users, idempotent
on re-run, does not affect other libraries.

Verified locally: seed --count=200 inserted user_library_mapping (user_id=1,
library_id=2 "Seed Library"); app returns 401 (auth gate) not 404 (no access)
for seeded book IDs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
zombor force-pushed bd-bookshelf-hj9.3 from 5df5a28eb6
All checks were successful
/ JS Unit Tests (pull_request) Successful in 29s
/ Lint (pull_request) Successful in 1m9s
/ E2E API (pull_request) Successful in 1m14s
/ Integration (pull_request) Successful in 2m1s
/ E2E Browser (pull_request) Successful in 2m2s
/ Test (pull_request) Successful in 2m8s
to 71bb62b823
All checks were successful
/ JS Unit Tests (pull_request) Successful in 28s
/ E2E API (pull_request) Successful in 1m32s
/ Lint (pull_request) Successful in 1m35s
/ Test (pull_request) Successful in 1m41s
/ Integration (pull_request) Successful in 2m7s
/ E2E Browser (pull_request) Successful in 2m32s
2026-06-18 14:42:42 +00:00
Compare
zombor merged commit 9aba71131d into main 2026-06-18 14:45:39 +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!601
No description provided.