penpot/backend/test/backend_tests/http_errors_test.clj
Andrey Antukh acd146f6f4
🐛 Restore rate-limit headers and add Retry-After on 429 (#11895)
* 🐛 Restore rate-limit headers and add Retry-After

The account-lockout change replaced the header-forwarding 429 handler
with a body-only one, so existing RPC rate-limit responses lost their
x-rate-limit-remaining and x-rate-limit-reset headers. Account lockout
never sent Retry-After either.

Make handle-error :rate-limit preserve ::http/headers and add a
retry-after header when the exception carries a non-nil :ttl in
seconds, keeping the current JSON body. Add focused tests for both the
lockout and the RPC limiter paths.

Document activation, defaults, password/LDAP scope, Redis fail-open
behavior, and the lockout risk, and record the final HTTP contract in
the backend auth memory.

Refs #11397

AI-assisted-by: deepseek-v4.1-flash

* 🐛 Add Retry-After to RPC 429 and expose headers in CORS

Address review follow-ups on the account-lockout 429 contract:

- The RPC limiter now sets retry-after in its 429 headers (seconds
  until the longest rejecting limit resets), so it matches the
  account-lockout response and the HTTP standard.
- CORS exposes retry-after, x-rate-limit-remaining, and
  x-rate-limit-reset so browser clients can read them.
- Use backticks for Retry-After and account-locked in the docs for
  consistency with nearby sections.

Refs #11397

AI-assisted-by: deepseek-v4.1-flash
2026-09-24 12:19:29 +02:00

71 lines
3.1 KiB
Clojure

;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.http-errors-test
"Focused tests for the HTTP error mapping in `app.http.errors`."
(:require
[app.http :as-alias http]
[app.http.errors :as http-errors]
[clojure.test :as t]
[yetti.response :as yres]))
(t/deftest account-lockout-returns-429-with-retry-after
(let [cause (ex-info "account locked"
{:type :rate-limit
:code :account-locked
:hint "account locked due to too many failed login attempts"
:ttl 900})
response (http-errors/handle cause {})
headers (::yres/headers response)
body (::yres/body response)]
(t/is (= 429 (::yres/status response)))
(t/is (= "900" (get headers "retry-after")))
(t/is (= :rate-limit (:type body)))
(t/is (= :account-locked (:code body)))
(t/is (= 900 (:ttl body)))))
(t/deftest rpc-rate-limit-preserves-headers
(let [cause (ex-info "rate limit reached"
{:type :rate-limit
:code :request-blocked
:hint "rate limit reached"
::http/headers {"x-rate-limit-remaining" "3"
"x-rate-limit-reset" "60"}})
response (http-errors/handle cause {})
headers (::yres/headers response)
body (::yres/body response)]
(t/is (= 429 (::yres/status response)))
(t/is (= "3" (get headers "x-rate-limit-remaining")))
(t/is (= "60" (get headers "x-rate-limit-reset")))
(t/is (nil? (get headers "retry-after")))
(t/is (= :rate-limit (:type body)))
(t/is (= :request-blocked (:code body)))))
(t/deftest rate-limit-with-ttl-keeps-headers-and-adds-retry-after
(let [cause (ex-info "rate limit reached"
{:type :rate-limit
:code :request-blocked
:hint "rate limit reached"
:ttl 120
::http/headers {"x-rate-limit-remaining" "0"
"x-rate-limit-reset" "120"}})
response (http-errors/handle cause {})
headers (::yres/headers response)]
(t/is (= 429 (::yres/status response)))
(t/is (= "0" (get headers "x-rate-limit-remaining")))
(t/is (= "120" (get headers "x-rate-limit-reset")))
(t/is (= "120" (get headers "retry-after")))))
(t/deftest rate-limit-without-ttl-has-no-retry-after
(let [cause (ex-info "rate limit reached"
{:type :rate-limit
:code :request-blocked
:hint "rate limit reached"})
response (http-errors/handle cause {})
headers (::yres/headers response)]
(t/is (= 429 (::yres/status response)))
(t/is (nil? (get headers "retry-after")))))