Commit Graph

2 Commits

Author SHA1 Message Date
32ed8e4411 fix(test): stop the suite reaching a real database, and make it exit
All checks were successful
PR Checks / client-build (pull_request) Successful in 25s
PR Checks / server-tests (pull_request) Successful in 1m34s
PR Checks / bot-install (pull_request) Successful in 8m45s
`npm test` never terminated. Twenty-two test files omitted the two lines that
point the pool at a dead port, so utils/db.js -- which builds its mariadb pool at
require time and calls dotenv.config() itself -- picked up server/.env and opened
five live connections to the developer's MariaDB. The tests still passed, because
they stub their models and never issue a query; the only symptoms were a process
that never exited and five connections held for as long as it lived. Thirty
stranded workers is 150 connections, which is the whole server's limit, and that
is the "too many connections" this workspace has hit before.

The convention was right and only ever as good as the next test file's memory of
it, so it moves into the harness: test/_setup.js is loaded with --require by the
npm script, ahead of the test file it hosts, which is the only moment early
enough to matter. It pins the dead port -- dotenv does not overwrite an existing
variable, so an explicit DB_PORT= still wins for anyone who wants a live database
-- and closes the pool after the file's tests, so the process exits at once
instead of waiting out the driver's connect retries. The per-file preambles stay:
they keep `node --test test/one.test.js` safe on its own.

Two supporting fixes:

- db.close() is idempotent. pool.end() throws "pool is already closed" on a
  second call, and closing twice is now normal rather than exceptional -- the
  harness closes the pool for every file on top of the suites that close it
  themselves, and a SIGINT followed by a SIGTERM already reached the shutdown
  handler twice.
- test/_helper.js's close() destroys open connections. server.close() only stops
  accepting and waits for existing connections to end, and node's global fetch
  keeps its sockets alive, so the listener outlived the test that created it --
  invisible until now, because the pool was holding the process open anyway.

announceJobs.test.js alone: 120s+ hang -> 0.35s. The whole suite now finishes in
~75s where it previously did not finish at all: 901 tests, 901 pass, verified
three times on CI's exact platform (node:20 on Linux, via Docker).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 21:27:47 -05:00
d38c98ad9e Harden admin login: RBAC-safe controls, 2FA, bot-scoring, rate limits (#9)
Adds a layered set of protections around the admin login and the app edge.

Trust proxy (server/src/utils/trustProxy.js)
- Configurable via TRUST_PROXY; pin to the newt agent ("ptero") LAN IP so
  X-Forwarded-For is trusted ONLY from that peer. A blanket "true" is
  rejected (coerced to 1) to prevent XFF spoofing that would dodge every
  IP-based control. DEBUG_TRUST_PROXY logs peer/XFF/req.ip to re-verify the
  proxy IP without a redeploy. Documents the Omada static-reservation
  assumption.

Login throttling (server/src/middleware/loginProtection.js, rateLimit.js)
- express-slow-down progressive delay + the existing hard rate cap + a
  separate per-IP exponential backoff that persists across the rate window.
  All failures return one generic message (no user/pass disclosure).

Honeypot (login form + auth.controller)
- Hidden, plausibly-named field ("company"); a filled value fails
  generically and is scored as an unambiguous bot.

Optional per-user TOTP 2FA (speakeasy/qrcode)
- totp_secret/totp_enabled columns (+ idempotent migration). Self-service
  Account page: enroll via QR, confirm a code to enable, code-gated disable.
- Login is two-step for enrolled users: after the password, a short-lived
  signed challenge (stage:'totp', not a session) is required before the
  real session is issued.

Bot / scanner scoring + IP ban (server/src/middleware/botScore.js)
- Weighted CMS-scanner paths (this app uses none). Junk paths 404 FIRST,
  unconditionally — independent of score/ban state, so a scanner rotating
  through fresh Cloudflare IPs gets no free pass. /wp-admin/install.php is
  the top-weighted near-1-hit ban (worst offender in prod logs). Per-IP
  score with quiet-period decay temp-bans an IP from ALL routes once past a
  (deliberately low) threshold, to protect /admin from credential stuffing.
  Failed logins and honeypot hits feed the same score.
- Periodic sweep evicts stale, unbanned, quiet entries so the in-memory
  store can't grow unbounded; the interval is unref'd and cleared on
  graceful shutdown.

Tests: node --test suite (40) covering trust-proxy parsing + live req.ip
(incl. pinned-IP), rate limiter + exponential backoff, honeypot rejection,
TOTP verify (enabled/disabled) + challenge-isn't-a-session, bot-score
threshold/decay/ban + junk-404-independence + install.php + store sweep.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:22:35 -05:00