Skip to content

Add terminal API tests and fix silent CSRF reload - #6

Merged
axisrow merged 2 commits into
mainfrom
fix/terminal-creation-tests
Mar 23, 2026
Merged

Add terminal API tests and fix silent CSRF reload#6
axisrow merged 2 commits into
mainfrom
fix/terminal-creation-tests

Conversation

@axisrow

@axisrow axisrow commented Mar 23, 2026

Copy link
Copy Markdown
Owner

Summary

  • Add 35 unit tests covering TTYDManager, CSRF tokens, and terminal route patterns
  • Fix silent page reload when CSRF token expires (root cause of "create terminal does nothing" bug)
  • Fix button text getting stuck on "Creating..." after failed requests

Test plan

  • python -m pytest tests/ — 128 tests pass (93 existing + 35 new)
  • Deploy to Sliplane and verify "+ New terminal" creates ttyd2
  • Wait >10 min on dashboard, click "+ New terminal" — should show "Session expired, reloading..." then reload with fresh CSRF token
  • Verify button resets to "+ New terminal" after any failure

🤖 Generated with Claude Code

Tests (35 new):
- test_ttyd_manager.py: TTYDManager create/delete/list/get, port
  allocation, limits, dead process cleanup
- test_csrf.py: CSRF token round-trip, expiry, tampering, edge cases
- test_terminals_api.py: TTYD route pattern matching

Fixes:
- Show "Session expired, reloading..." on CSRF 419 instead of
  silently reloading the page (caused "nothing happens" on button)
- Reset button text in .finally() if stuck on "Creating..."

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@axisrow

axisrow commented Mar 23, 2026

Copy link
Copy Markdown
Owner Author

@claude review. Focus on critical issues: bugs, security vulnerabilities, logical errors, data loss risks, performance problems. Do NOT nitpick style, naming conventions, minor formatting, or subjective preferences — only flag issues that could break functionality or cause real harm in production.

@claude

claude Bot commented Mar 23, 2026

Copy link
Copy Markdown

Claude finished @axisrow's task in 2m 22s —— View job


PR Review: Add terminal API tests and fix silent CSRF reload

  • Read changed files
  • Analyze for critical issues
  • Post review findings

Summary

The production fix in index.html is correct and addresses the reported bugs. The tests are well-structured but have a fidelity problem worth knowing about.


Issues Found

1. Tests copy production code instead of importing — silent divergence risk (medium)

tests/unit/test_ttyd_manager.py inlines a copy of TTYDManager that diverges from production in two places:

  • delete_terminal: The test copy omits the tmux session cleanup (subprocess.run(["tmux", "kill-session", ...])) that exists in production. test_delete_terminal_success passes but never verifies tmux cleanup is called — a future regression there would go undetected.
  • list_terminals: The test copy deletes dead terminals inside the lock only. Production calls self._cleanup_dead(tid, info) outside the lock afterwards. Bugs introduced in _cleanup_dead won't be caught by these tests.

The comment # Copied from ttyd_proxy.py for testing is there for a reason (Linux-only deps), but the copies have already drifted. This is the most significant concern with this PR.

Fix this →


2. test_delete_terminal_kill_on_timeout does not test the kill-succeeds path (minor)

mock_proc.wait.side_effect = subprocess.TimeoutExpired("ttyd", 5)

side_effect applies to all calls to wait(). So the second process.wait(timeout=3) (after kill()) also raises TimeoutExpired — it falls into the except (subprocess.TimeoutExpired, OSError): pass branch. The test is actually validating the "both terminate and kill timeout" scenario, not "kill succeeds after terminate timeout." The assertion mock_proc.kill.assert_called_once() passes but the kill-success path has no test coverage.

Fix this →


3. Button briefly re-enabled during 419 CSRF reload window (low)

handleAuthError (line 123) sets a 500ms timeout before reload. But finally runs synchronously immediately after, calling btn.disabled = false. For those 500ms the button is both enabled and shows "Session expired, reloading..." — a rapid double-click could fire a second POST before the reload. The second request will also 419 and trigger another reload cycle, so no data is lost, but it's a minor UX inconsistency.


What looks good

  • The core fix is correct: 419 now shows user-visible feedback instead of silently reloading, resolving the "create terminal does nothing" bug.
  • The finally check btn.textContent === 'Creating...' correctly handles the success path reset without clobbering error messages set by the .then/.catch handlers.
  • CSRF token round-trip tests are thorough (tamper, expiry, whitespace, bad base64).
  • Route pattern tests cover edge cases (/ttyd no-number, alpha suffix, nested paths).
  • No security issues found in the production changes.

@axisrow

axisrow commented Mar 23, 2026

Copy link
Copy Markdown
Owner Author

Triage of review comments

Comment 1 — Test copy diverges from production (missing tmux cleanup, _cleanup_dead)

Verdict: FIX. Confirmed — test copy was simplified. Fixed: added tmux cleanup in delete_terminal, added _cleanup_dead method, moved cleanup outside lock in list_terminals/get_terminal. Added test_delete_terminal_kills_tmux_session.

Comment 2 — test_delete_terminal_kill_on_timeout tests wrong path

Verdict: FIX. Confirmed — side_effect hit both wait() calls. Fixed with list side_effect: [TimeoutExpired, None].

Comment 3 — Button re-enabled during 419 reload window

Verdict: SKIP. Cosmetic — double-click during 500ms window produces another 419 which also reloads. No data loss or security impact.

- Add tmux kill-session cleanup to delete_terminal test copy
- Add _cleanup_dead method matching production implementation
- Move cleanup outside lock in list_terminals/get_terminal
- Fix test_delete_terminal_kill_on_timeout: use list side_effect
  so only first wait() raises TimeoutExpired
- Add test_delete_terminal_kills_tmux_session
- Add subprocess.run mocks where tmux cleanup runs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@axisrow

axisrow commented Mar 23, 2026

Copy link
Copy Markdown
Owner Author

@claude review. Focus on critical issues: bugs, security vulnerabilities, logical errors, data loss risks, performance problems. Do NOT nitpick style, naming conventions, minor formatting, or subjective preferences — only flag issues that could break functionality or cause real harm in production.

@claude

claude Bot commented Mar 23, 2026

Copy link
Copy Markdown

Claude finished @axisrow's task in 2m 53s —— View job


PR Review: Add terminal API tests and fix silent CSRF reload

  • Read changed files
  • Analyze for critical issues
  • Post review findings

Summary

The two issues flagged in the previous review have been properly fixed. The production fix is correct. No new critical issues found — this PR looks ready to merge.


Previous Issues — Verified Fixed

Issue 1 — Test copy diverged from production (delete_terminal, list_terminals):

Fixed. The test copy now includes:

  • subprocess.run(["runuser", ..., "tmux", "kill-session", ...]) in delete_terminal
  • _cleanup_dead() method matching production logic ✓
  • _cleanup_dead called outside the lock in list_terminals and get_terminal
  • New test_delete_terminal_kills_tmux_session asserting tmux kill-session is invoked with the correct session name ✓

Issue 2 — test_delete_terminal_kill_on_timeout tested wrong path:

Fixed. side_effect is now a list [TimeoutExpired("ttyd", 5), None], so only the first wait() raises and the second returns normally. The test now correctly validates the "terminate times out → kill → kill succeeds" path ✓


Production Fix Correctness Verified

createTerminal() on 419:

  1. handleAuthError sets button text to "Session expired, reloading..." and schedules reload at 500ms
  2. First .then returns undefined
  3. Second .then hits if (!data) return; (line 180) — no TypeError, no catch
  4. finally re-enables the button; textContent check fails (!== 'Creating...') so no reset
  5. Page reloads at 500ms

The if (!data) return; guard is the key piece that makes this safe. The "Session expired, reloading..." text persists correctly until reload.


One Observation (not blocking)

test_delete_terminal_success patches subprocess.run but doesn't assert it was called. The tmux cleanup assertion is covered by test_delete_terminal_kills_tmux_session instead. The separation is intentional and acceptable — just noting it so the intent is clear in code review.


What Looks Good

  • All 35 new tests pass alongside the 93 existing ones
  • The if (!data) return; guard prevents 419 from triggering the catch "Error — refresh page" path
  • CSRF token tests are thorough: expiry, tamper, whitespace, bad base64, wrong part count
  • Route pattern tests cover edge cases: no number, alpha suffix, nested paths, /ttyd0
  • No security issues in production changes

@axisrow
axisrow merged commit 6946836 into main Mar 23, 2026
1 check passed
@axisrow
axisrow deleted the fix/terminal-creation-tests branch March 23, 2026 09:19
axisrow added a commit that referenced this pull request Jul 8, 2026
)

Six P1 correctness/reliability fixes from the #101 code review.

#3 (proxy.py): enable SO_KEEPALIVE on both tunnel sockets so a client that
vanishes without a FIN (laptop sleep, NAT timeout) is detected by the OS and the
idle tunnel thread + sockets are freed instead of leaking forever.

#4 (ratelimit.py): use time.monotonic() instead of wall-clock time.time(), so a
backward clock jump (NTP correction, suspend/resume, VM migration) cannot freeze
the window and lock out legitimate users past 60s/300s.

#5 (app.py): _check_auth now honours redirect=True in the removed-user branch —
a browser navigation with a valid cookie whose account was deleted/renamed lands
on /login (302) instead of a raw 403 JSON blob.

#6 (proxy.py): add "cookie" to HOP_BY_HOP_HEADERS so the client's signed
ttyd_session + csrf_token is not forwarded to the internal ttyd (defense depth).

#10 (bin/clihost-sync.sh): the remote symlink guards were byte-identical
triplicates (local pair + one copy in each of two `bash -s` heredocs), so
hardening one silently bypassed the others — the class of bug caught 3× before
(#88/#90/#95). Define them once in a shared emit_remote_prelude injected into
both remote payloads.

#11 (.env.example): document SESSION_TIMEOUT, CLEANUP_ROOT, ROOT_PASSWORD,
HAPI_USER, HERMES_AUTO_UPDATE (read by code + CLAUDE.md but missing from the
example), plus the REQUEST_TIMEOUT slowloris knob for completeness.

New/changed env vars documented in .env.example: SESSION_TIMEOUT, CLEANUP_ROOT,
ROOT_PASSWORD, HAPI_USER, HERMES_AUTO_UPDATE, REQUEST_TIMEOUT. No port or volume
changes.

Closes #101 findings #3, #4, #5, #6, #10, #11.


Claude-Session: https://claude.ai/code/session_01KgvJhzd8gEhXVE4MVu18nn

Co-authored-by: axisrow <axisrow@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant