penpot/frontend/test/frontend_tests/main_errors_test.cljs

372 lines
15 KiB
Clojure
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

;; 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 INC Sucursal en España SL
(ns frontend-tests.main-errors-test
"Unit tests for app.main.errors.
Tests cover:
- stale-asset-error? pure predicate
- exception->error-data pure transformer
- on-error re-entrancy guard prevents recursive invocations
- flash schedules async emit ntf/show is not emitted synchronously
- organization SSO recovery expired SSO sessions go back to the provider"
(:require
[app.main.errors :as errors]
[app.main.repo :as rp]
[app.main.router :as rt]
[app.main.store :as st]
[app.util.timers :as tm]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.mock :as mock]
[potok.v2.core :as ptk]))
;; ---------------------------------------------------------------------------
;; stale-asset-error?
;; ---------------------------------------------------------------------------
(t/deftest stale-asset-error-nil
(t/testing "nil cause returns nil/falsy"
(t/is (not (errors/stale-asset-error? nil)))))
(t/deftest stale-asset-error-keyword-cst-undefined
(t/testing "error with $cljs$cst$ and 'is undefined' is recognised"
(let [err (js/Error. "foo$cljs$cst$bar is undefined")]
(t/is (true? (boolean (errors/stale-asset-error? err)))))))
(t/deftest stale-asset-error-keyword-cst-null
(t/testing "error with $cljs$cst$ and 'is null' is recognised"
(let [err (js/Error. "foo$cljs$cst$bar is null")]
(t/is (true? (boolean (errors/stale-asset-error? err)))))))
(t/deftest stale-asset-error-protocol-dispatch-undefined
(t/testing "error with $cljs$core$I and 'Cannot read properties of undefined' is recognised"
(let [err (js/Error. "Cannot read properties of undefined (reading '$cljs$core$IFn$_invoke$arity$1$')")]
(t/is (true? (boolean (errors/stale-asset-error? err)))))))
(t/deftest stale-asset-error-not-a-function
(t/testing "error with $cljs$cst$ and 'is not a function' is recognised"
(let [err (js/Error. "foo$cljs$cst$bar is not a function")]
(t/is (true? (boolean (errors/stale-asset-error? err)))))))
(t/deftest stale-asset-error-unrelated-message
(t/testing "ordinary error without stale-asset signature is NOT recognised"
(let [err (js/Error. "Cannot read properties of undefined (reading 'foo')")]
(t/is (not (errors/stale-asset-error? err))))))
(t/deftest stale-asset-error-only-cst-no-undefined
(t/testing "error with $cljs$cst$ but no undefined/null/not-a-function keyword is not recognised"
(let [err (js/Error. "foo$cljs$cst$bar exploded")]
(t/is (not (errors/stale-asset-error? err))))))
;; ---------------------------------------------------------------------------
;; exception->error-data
;; ---------------------------------------------------------------------------
(t/deftest exception->error-data-plain-error
(t/testing "plain JS Error is converted to a data map with :hint and ::instance"
(let [err (js/Error. "something went wrong")
data (errors/exception->error-data err)]
(t/is (= "something went wrong" (:hint data)))
(t/is (identical? err (::errors/instance data))))))
(t/deftest exception->error-data-ex-info
(t/testing "ex-info error preserves existing :hint and attaches ::instance"
(let [err (ex-info "original" {:hint "my-hint" :type :network})
data (errors/exception->error-data err)]
(t/is (= "my-hint" (:hint data)))
(t/is (= :network (:type data)))
(t/is (identical? err (::errors/instance data))))))
(t/deftest exception->error-data-ex-info-no-hint
(t/testing "ex-info without :hint falls back to ex-message"
(let [err (ex-info "fallback message" {:type :validation})
data (errors/exception->error-data err)]
(t/is (= "fallback message" (:hint data))))))
;; ---------------------------------------------------------------------------
;; on-error dispatches to ptk/handle-error
;;
;; We use a dedicated test-only error type so we can add/remove a
;; defmethod without touching the real handlers.
;; ---------------------------------------------------------------------------
(def ^:private test-handled (atom nil))
(defmethod ptk/handle-error ::test-dispatch
[err]
(reset! test-handled err))
(t/deftest on-error-dispatches-map-error
(t/testing "on-error dispatches a map error to ptk/handle-error using its :type"
(reset! test-handled nil)
(errors/on-error {:type ::test-dispatch :hint "hello"})
(t/is (= ::test-dispatch (:type @test-handled)))
(t/is (= "hello" (:hint @test-handled)))))
(t/deftest on-error-wraps-exception-then-dispatches
(t/testing "on-error wraps a JS Error into error-data before dispatching"
(reset! test-handled nil)
(let [err (ex-info "wrapped" {:type ::test-dispatch})]
(errors/on-error err)
(t/is (= ::test-dispatch (:type @test-handled)))
(t/is (identical? err (::errors/instance @test-handled))))))
;; ---------------------------------------------------------------------------
;; on-error re-entrancy guard
;;
;; The guard is implemented via the `handling-error?` volatile inside
;; app.main.errors. We can verify its effect by registering a
;; handle-error method that itself calls on-error and checking that
;; only one invocation gets through.
;; ---------------------------------------------------------------------------
(def ^:private reentrant-call-count (atom 0))
(defmethod ptk/handle-error ::test-reentrant
[_err]
(swap! reentrant-call-count inc)
;; Simulate a secondary error inside the error handler
;; (e.g. the notification emit itself throws).
;; Without the re-entrancy guard this would recurse indefinitely.
(when (= 1 @reentrant-call-count)
(errors/on-error (ex-info "test" {:type ::test-reentrant :hint "secondary"}))))
(t/deftest on-error-reentrancy-guard-prevents-recursion
(t/testing "a second on-error call while handling an error is suppressed by the guard"
(reset! reentrant-call-count 0)
(errors/on-error (ex-info "test" {:type ::test-reentrant :hint "first"}))
;; The guard must have allowed only the first invocation through.
(t/is (= 1 @reentrant-call-count))))
;; ---------------------------------------------------------------------------
;; Expired organization SSO session
;;
;; The backend rejects SSO-guarded requests with an :authentication error
;; coded :nitrate-sso-required once the organization SSO session lapses.
;; The user must be sent back through the identity provider instead of
;; being told they have no access to the file.
;; ---------------------------------------------------------------------------
(def ^:private workspace-href
"https://penpot.example.com/#/workspace?team-id=b8f8bb52-8b70-8144-8004-4a5085f0bdc9")
(def ^:private organization-id "d1a4c0f2-2f36-8114-8006-1b0e6d9d0c11")
(defn- sso-required-error
[]
{:type :authentication
:code :nitrate-sso-required
:organization-id organization-id
:team-id "b8f8bb52-8b70-8144-8004-4a5085f0bdc9"})
(t/deftest expired-organization-sso-navigates-to-identity-provider
(t/testing "the browser is sent to the identity provider instead of an error page"
(let [events (atom [])]
(with-redefs [rp/cmd!
(mock/stub
(fn [_command _params]
(rx/of {:authorized false
:redirect-uri "https://idp.example.com/authorize"})))
rt/get-current-href
(constantly workspace-href)
st/emit!
(mock/stub (fn [& emitted] (swap! events into emitted)))]
(errors/on-error (sso-required-error))
(t/is (= [::rt/nav-raw] (mapv ptk/type @events)))))))
(t/deftest expired-organization-sso-comes-back-to-the-current-location
(t/testing "the SSO check asks the provider to return the user where they were"
(let [rpc-calls (atom [])]
(with-redefs [rp/cmd!
(mock/stub
(fn [command params]
(swap! rpc-calls conj {:command command :params params})
(rx/of {:authorized false
:redirect-uri "https://idp.example.com/authorize"})))
rt/get-current-href
(constantly workspace-href)
st/emit! mock/noop]
(errors/on-error (sso-required-error))
(t/is (= [{:command :check-nitrate-sso
:params {:team-id "b8f8bb52-8b70-8144-8004-4a5085f0bdc9"
:organization-id organization-id
:url workspace-href}}]
@rpc-calls))))))
(t/deftest already-satisfied-organization-sso-retries-the-location
(t/testing "a session renewed meanwhile (e.g. in another tab) reloads instead of erroring"
(let [events (atom [])]
(with-redefs [rp/cmd!
(mock/stub
(fn [_command _params]
(rx/of {:authorized true :reason :sso-satisfied})))
rt/get-current-href
(constantly workspace-href)
st/emit!
(mock/stub (fn [& emitted] (swap! events into emitted)))]
(errors/on-error (sso-required-error))
(t/is (= [::rt/reload] (mapv ptk/type @events)))))))
(t/deftest organization-sso-without-usable-provider-shows-the-sso-error-dialog
(t/testing "SSO is required but there is nowhere to go: offer a retry, not a permission error"
(let [assigned* (atom nil)]
(with-redefs [rp/cmd!
(mock/stub
(fn [_command _params]
(rx/of {:authorized false :redirect-uri nil})))
rt/get-current-href
(constantly workspace-href)
rt/assign-exception
(fn [error]
(reset! assigned* error)
(ptk/data-event ::assigned error))]
(errors/on-error (sso-required-error))
(t/is (= :sso-error (:type @assigned*)))
(t/is (= organization-id (:organization-id @assigned*)))
(t/is (true? (:is-workspace @assigned*)))))))
(t/deftest organization-sso-without-team-access-reports-a-permission-failure
(t/testing "a user who cannot reach the team keeps getting the authentication error"
(let [assigned* (atom nil)]
(with-redefs [rp/cmd!
(mock/stub
(fn [_command _params]
(rx/of {:authorized true :reason :no-team-access})))
rt/get-current-href
(constantly workspace-href)
rt/assign-exception
(fn [error]
(reset! assigned* error)
(ptk/data-event ::assigned error))]
(errors/on-error (sso-required-error))
(t/is (= :authentication (:type @assigned*)))
(t/is (= :nitrate-sso-required (:code @assigned*)))))))
(t/deftest organization-sso-does-not-retry-on-an-unexplained-authorization
(t/testing "reloading on an answer we don't understand would spin on the same rejection"
(let [events (atom [])]
(with-redefs [rp/cmd!
(mock/stub (fn [_command _params] (rx/of {:authorized true})))
rt/get-current-href
(constantly workspace-href)
rt/assign-exception
(fn [error] (ptk/data-event ::assigned error))
;; async-emit! is variadic-only, so the replacement must be
;; variadic too for the compiled static dispatch to find it
st/async-emit!
(fn [& emitted] (swap! events into emitted))]
(errors/on-error (sso-required-error))
(t/is (= [::assigned] (mapv ptk/type @events)))))))
(t/deftest organization-sso-error-without-context-is-reported-as-it-arrives
(t/testing "with no organization and no team there is nothing to check"
(let [rpc-calls (atom 0)
assigned* (atom nil)]
(with-redefs [rp/cmd!
(mock/stub (fn [_command _params]
(swap! rpc-calls inc)
(rx/empty)))
rt/get-current-href
(constantly workspace-href)
rt/assign-exception
(fn [error]
(reset! assigned* error)
(ptk/data-event ::assigned error))]
(errors/on-error {:type :authentication
:code :nitrate-sso-required})
(t/is (zero? @rpc-calls))
(t/is (= :nitrate-sso-required (:code @assigned*)))))))
(t/deftest a-resultless-organization-sso-check-does-not-wedge-later-rejections
(t/testing "the one-in-flight guard is released even when no answer arrives"
(let [rpc-calls (atom 0)]
(with-redefs [rp/cmd!
(mock/stub (fn [_command _params]
(swap! rpc-calls inc)
(rx/empty)))
rt/get-current-href
(constantly workspace-href)
st/emit! mock/noop]
(errors/on-error (sso-required-error))
(errors/on-error (sso-required-error))
(t/is (= 2 @rpc-calls))))))
;; A failing check must stay a failing check: the generic handling turns it
;; into a toast, whereas swallowing it would show a permission error for
;; what may be a momentary network blip. The mocked RPC fails on a later
;; tick, like a real request, so the handler is not inside on-error's
;; re-entrancy guard when the failure arrives.
(def ^:private check-failures (atom []))
(defmethod ptk/handle-error ::test-check-failure
[error]
(swap! check-failures conj error))
(t/deftest failing-organization-sso-check-is-not-reported-as-missing-access
(t/async done
(reset! check-failures [])
(let [assigned* (atom nil)]
(mock/with-mocks
{rp/cmd!
(mock/stub
(fn [_command _params]
(->> (rx/timer 0)
(rx/mapcat (fn [_]
(rx/throw (ex-info "boom" {:type ::test-check-failure})))))))
rt/get-current-href
(constantly workspace-href)
rt/assign-exception
(fn [error]
(reset! assigned* error)
(ptk/data-event ::assigned error))}
(fn [done']
(errors/on-error (sso-required-error))
(tm/schedule
50
(fn []
(t/is (= [::test-check-failure] (mapv :type @check-failures)))
(t/is (nil? @assigned*))
(done'))))
done))))