mirror of
https://github.com/penpot/penpot.git
synced 2026-09-24 21:06:14 +00:00
Classify save failures as transient or terminal (`transient-error?` over the repo retryable types plus `:invalid-save-response`). Transient failures keep the head commit queued under a new `:retrying` status and resend it with backoff (2s/8s/20s, then terminal): stamp rotation reuses the same `:commit-id`, the in-flight guard prevents double-sends, and episode tokens silence stale timers. One tagged reconnect notice per episode (hidden on save and on terminal failure, silent recovery) plus a `:retrying` save-indicator state; the browser `online` event and new edits resume the episode. Terminal failures keep the exact `:error` path. Covers tasks 4, 6 and 7 with 31 persistence tests; updates the persistence memory. Relates to #11724 AI-assisted-by: muse-spark-1.3-contributor
789 lines
39 KiB
Clojure
789 lines
39 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 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 report and toast – neither the report nor
|
||
ntf/show is emitted synchronously
|
||
- organization SSO recovery – expired SSO sessions go back to the provider
|
||
- invalid-sso-config handler – requires :organization-id to promote to :sso-error
|
||
- save failure notification – sticky toast carrying the error report
|
||
- delegated save failures – causes handled by the handler for their type"
|
||
(:require
|
||
[app.common.uuid :as uuid]
|
||
[app.main.data.event :as ev]
|
||
[app.main.data.persistence :as dps]
|
||
[app.main.data.workspace :as-alias dw]
|
||
[app.main.errors :as errors]
|
||
[app.main.refs :as refs]
|
||
[app.main.repo :as rp]
|
||
[app.main.router :as rt]
|
||
[app.main.store :as st]
|
||
[app.util.dom :as dom]
|
||
[app.util.i18n :as i18n]
|
||
[app.util.timers :as tm]
|
||
[app.util.webapi :as wapi]
|
||
[beicon.v2.core :as rx]
|
||
[cljs.test :as t :include-macros true]
|
||
[cuerdas.core :as str]
|
||
[frontend-tests.helpers.async :as async]
|
||
[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))))))
|
||
|
||
;; Shared report-test helpers (copied from the governor suite so each
|
||
;; namespace stays self-contained).
|
||
(t/use-fixtures :each {:before #(errors/reset-report-governor!)})
|
||
|
||
(defn- error-cause
|
||
[& {:keys [type code hint]}]
|
||
(ex-info (or hint "boom")
|
||
(cond-> {}
|
||
(some? type) (assoc :type type)
|
||
(some? code) (assoc :code code)
|
||
(some? hint) (assoc :hint hint))))
|
||
|
||
(defn- capture-reports!
|
||
"Run `f` capturing the events emitted through `st/emit!`."
|
||
[f]
|
||
(let [events (atom [])]
|
||
(with-redefs [st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))
|
||
rt/get-current-href (constantly "https://penpot.example.com/#/workspace")]
|
||
(f)
|
||
@events)))
|
||
|
||
(defn- report-events
|
||
"The audit events (report emissions) out of everything collected through
|
||
the `st/emit!` double. Toasts are ungoverned by design — one per `flash`
|
||
call — so only the events typed as audit events count for emission
|
||
bounds. Discriminates by `ptk/type`, which is total (never throws),
|
||
because toasts are not derefable."
|
||
[events]
|
||
(filter #(= ::ev/event (ptk/type %)) events))
|
||
|
||
;; ---------------------------------------------------------------------------
|
||
;; Environment failures
|
||
;;
|
||
;; Connectivity and service failures are not application defects: they are
|
||
;; audit-only telemetry with a compact report and a dedicated toast.
|
||
;; ---------------------------------------------------------------------------
|
||
|
||
(t/deftest environment-error-classification
|
||
(t/testing "environment failures are recognised"
|
||
(doseq [type [:network :offline :bad-gateway :service-unavailable
|
||
:nitrate-unavailable :nitrate-not-configured]]
|
||
(t/is (true? (errors/environment-error? (error-cause :type type)))
|
||
(str "expected environment error: " type))))
|
||
(t/testing "application defects are not environment failures"
|
||
(doseq [type [:internal :assertion :persistence :validation :authentication]]
|
||
(t/is (false? (errors/environment-error? (error-cause :type type)))
|
||
(str "did not expect environment error: " type))))
|
||
(t/testing "causes without ex-data are not environment failures"
|
||
(t/is (false? (errors/environment-error? (js/Error. "plain failure"))))
|
||
(t/is (false? (errors/environment-error? nil)))))
|
||
|
||
(t/deftest ^:async generate-report-compact-omits-stack-data-and-last-events
|
||
(await
|
||
(mock/with-mocks*
|
||
{st/format-last-events (mock/stub (fn [& _] (throw (ex-info "must not be called" {}))))
|
||
rt/get-current-href (constantly "https://penpot.example.com/#/workspace")}
|
||
(let [cause (ex-info "http error" {:type :offline
|
||
:hint "http error"
|
||
:uri "/api/rpc/command/update-file"
|
||
:headers {"x-session-id" "secret"}})
|
||
report (errors/generate-report cause {:format :compact})]
|
||
(t/is (string? report))
|
||
(t/is (str/includes? report "Hint:"))
|
||
(t/is (str/includes? report "http error"))
|
||
(t/is (str/includes? report ":offline"))
|
||
(t/is (str/includes? report "/api/rpc/command/update-file"))
|
||
(t/is (not (str/includes? report "Last events:")))
|
||
(t/is (not (str/includes? report "Data:")))
|
||
(t/is (not (str/includes? report "====")))
|
||
(t/is (not (str/includes? report "secret")))
|
||
;; Trailing settle: the body is synchronous, but `with-mocks*`
|
||
;; evaluates to a promise, so the body must settle one.
|
||
(await (async/settle))))))
|
||
|
||
(t/deftest ^:async generate-report-defaults-to-the-full-format
|
||
(await
|
||
(mock/with-mocks*
|
||
{st/format-last-events (mock/stub (fn [& _] "(stub last events)"))
|
||
rt/get-current-href (constantly "https://penpot.example.com/#/workspace")}
|
||
(let [report (errors/generate-report (error-cause :type :internal :hint "boom"))]
|
||
(t/is (str/includes? report "Last events:"))
|
||
(t/is (str/includes? report "(stub last events)"))
|
||
;; Trailing settle: the body is synchronous, but `with-mocks*`
|
||
;; evaluates to a promise, so the body must settle one.
|
||
(await (async/settle))))))
|
||
|
||
(t/deftest ^:async connectivity-handlers-report-governed-compact-audit-events
|
||
;; Scenario: a network failure and an offline status through the global
|
||
;; handler. Each is reported as a governed compact audit event plus its
|
||
;; connection toast. Proves: per type, report first and toast right after,
|
||
;; with no stack in the report and the dedicated toast message.
|
||
(doseq [type [:network :offline]]
|
||
(errors/reset-report-governor!)
|
||
(let [events (atom [])
|
||
cause (ex-info "http error" {:type type :hint "http error"})]
|
||
(await
|
||
(mock/with-mocks*
|
||
{st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))
|
||
rt/get-current-href (constantly "https://penpot.example.com/#/workspace")
|
||
tm/schedule (mock/stub (fn [f] (f)))
|
||
st/format-last-events (mock/stub (fn [& _] (throw (ex-info "must not be called" {}))))}
|
||
(errors/on-error cause)
|
||
(await (async/settle))
|
||
;; `flash` emits the report first and schedules the toast right after.
|
||
(t/is (= 2 (count @events)) (str "unexpected event count for " type))
|
||
(let [report-event (first @events)
|
||
toast-event (second @events)
|
||
props (deref report-event)]
|
||
(t/is (= "handled-exception" (::ev/name props)))
|
||
(t/is (not (str/includes? (:report props) "Last events:")))
|
||
(t/is (= (i18n/tr "errors.connection-error")
|
||
(get-in (ptk/update toast-event {}) [:notification :content])))))))))
|
||
|
||
(t/deftest ^:async flash-keeps-the-canonical-event-name-and-derives-the-format
|
||
;; Scenario: an environment failure flashed as `:handled` and as
|
||
;; `:unhandled`. The audit event name is the canonical one requested by
|
||
;; the caller while only the payload format derives from the cause
|
||
;; (compact, with no stack and no header dump). Proves: per type, one
|
||
;; compact audit event under the requested name, with the `flash`
|
||
;; awaited to completion.
|
||
(let [cause (ex-info "http error" {:type :network
|
||
:hint "http error"
|
||
:headers {"x-session-id" "secret"}})]
|
||
(doseq [[type event-name] [[:handled "handled-exception"]
|
||
[:unhandled "unhandled-exception"]]]
|
||
(errors/reset-report-governor!)
|
||
(let [events (atom [])]
|
||
(await
|
||
(mock/with-mocks*
|
||
{st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))
|
||
rt/get-current-href (constantly "https://penpot.example.com/#/workspace")
|
||
tm/schedule (mock/stub (fn [f] (mock/asap f)))
|
||
st/format-last-events (mock/stub (fn [& _] (throw (ex-info "must not be called" {}))))}
|
||
;; The event name is the canonical one requested by the caller;
|
||
;; only the payload format is derived from the cause.
|
||
(await (errors/flash :cause cause :type type))
|
||
(let [reports (report-events @events)]
|
||
(t/is (= 1 (count reports)) (str "unexpected event count for " type))
|
||
(let [props (deref (first reports))
|
||
report (:report props)]
|
||
(t/is (= event-name (::ev/name props)) (str "event name for " type))
|
||
(t/is (not (str/includes? report "Last events:")))
|
||
(t/is (not (str/includes? report "secret")))))))))))
|
||
|
||
(t/deftest ^:async offline-loop-is-governed-and-never-unhandled
|
||
;; Scenario: 10 000 offline errors through the global handler, replaying
|
||
;; a connectivity outage. Every occurrence is handled (never unhandled)
|
||
;; and the governor emits only the first. Proves: one `handled-exception`
|
||
;; audit event for the whole loop, observed after timers drain.
|
||
(let [events (atom [])
|
||
cause (ex-info "http error" {:type :offline :hint "http error"})]
|
||
(await
|
||
(mock/with-mocks*
|
||
{st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))
|
||
rt/get-current-href (constantly "https://penpot.example.com/#/workspace")
|
||
tm/schedule (mock/stub (fn [f] (mock/asap f)))
|
||
st/format-last-events (mock/stub (fn [& _] (throw (ex-info "must not be called" {}))))}
|
||
(dotimes [_ 10000]
|
||
(errors/on-error cause))
|
||
(await (async/settle))
|
||
(t/is (= 1 (count (report-events @events))))
|
||
(t/is (= "handled-exception" (::ev/name (deref (first (report-events @events))))))))))
|
||
|
||
(t/deftest ^:async flash-persistence-uses-compact-reports-for-environment-failures
|
||
;; Scenario: a save failure caused by the environment. `flash-persistence`
|
||
;; delegates to `flash`, so the audit event carries a compact report
|
||
;; (context only: no stack, no header dump). Proves: one
|
||
;; `handled-exception` with a compact payload, observed after timers
|
||
;; drain.
|
||
(let [events (atom [])]
|
||
(await
|
||
(mock/with-mocks*
|
||
{st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))
|
||
rt/get-current-href (constantly "https://penpot.example.com/#/workspace")
|
||
tm/schedule (mock/stub (fn [f] (mock/asap f)))
|
||
st/format-last-events (mock/stub (fn [& _] (throw (ex-info "must not be called" {}))))}
|
||
(errors/flash-persistence (ex-info "http error" {:type :offline
|
||
:hint "http error"
|
||
:headers {"x-session-id" "secret"}}))
|
||
(await (async/settle))
|
||
(let [reports (report-events @events)]
|
||
(t/is (= 1 (count reports)))
|
||
(let [props (deref (first reports))]
|
||
(t/is (= "handled-exception" (::ev/name props)))
|
||
(t/is (not (str/includes? (:report props) "Last events:")))
|
||
(t/is (not (str/includes? (:report props) "secret")))))))))
|
||
|
||
(t/deftest exception-page-reports-dedup-by-cause
|
||
(let [cause-a (error-cause :type :internal :code :unable-to-process-repository-response :hint "boom")
|
||
cause-b (error-cause :type :internal :code :other :hint "other")
|
||
events (capture-reports!
|
||
(fn []
|
||
(errors/submit-report :event-name "exception-page"
|
||
:report "report" :hint "boom" :cause cause-a)
|
||
(errors/submit-report :event-name "exception-page"
|
||
:report "report" :hint "different hint" :cause cause-a)
|
||
(errors/submit-report :event-name "exception-page"
|
||
:report "report" :hint "other" :cause cause-b)))]
|
||
(t/is (= 2 (count events)))))
|
||
|
||
(t/deftest reports-of-the-same-cause-under-different-names-do-not-coalesce
|
||
(let [cause (error-cause :type :internal :hint "boom")
|
||
events (capture-reports!
|
||
(fn []
|
||
(errors/submit-report :event-name "handled-exception"
|
||
:report "report" :hint "boom" :cause cause)
|
||
(errors/submit-report :event-name "unhandled-exception"
|
||
:report "report" :hint "boom" :cause cause)
|
||
(errors/submit-report :event-name "exception-page"
|
||
:report "report" :hint "boom" :cause cause)))]
|
||
(t/is (= 3 (count events)))))
|
||
|
||
;; ---------------------------------------------------------------------------
|
||
;; 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 ^:async 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 [])]
|
||
(await
|
||
(mock/with-mocks*
|
||
{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))
|
||
(await (async/settle))
|
||
(t/is (= [::rt/nav-raw] (mapv ptk/type @events))))))))
|
||
|
||
(t/deftest ^:async 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 [])]
|
||
(await
|
||
(mock/with-mocks*
|
||
{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))
|
||
(await (async/settle))
|
||
(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 ^:async 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 [])]
|
||
(await
|
||
(mock/with-mocks*
|
||
{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))
|
||
(await (async/settle))
|
||
(t/is (= [::rt/reload] (mapv ptk/type @events))))))))
|
||
|
||
(t/deftest ^:async 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)]
|
||
(await
|
||
(mock/with-mocks*
|
||
{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))
|
||
(await (async/settle))
|
||
(t/is (= :sso-error (:type @assigned*)))
|
||
(t/is (= organization-id (:organization-id @assigned*)))
|
||
(t/is (true? (:is-workspace @assigned*))))))))
|
||
|
||
(t/deftest ^:async 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)]
|
||
(await
|
||
(mock/with-mocks*
|
||
{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))
|
||
(await (async/settle))
|
||
(t/is (= :authentication (:type @assigned*)))
|
||
(t/is (= :nitrate-sso-required (:code @assigned*))))))))
|
||
|
||
(t/deftest ^:async 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 [])]
|
||
(await
|
||
(mock/with-mocks*
|
||
{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))
|
||
st/async-emit! (fn [& emitted] (swap! events into emitted))}
|
||
(errors/on-error (sso-required-error))
|
||
(await (async/settle))
|
||
(t/is (= [::assigned] (mapv ptk/type @events))))))))
|
||
|
||
(t/deftest ^:async 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)]
|
||
(await
|
||
(mock/with-mocks*
|
||
{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})
|
||
(await (async/settle))
|
||
(t/is (zero? @rpc-calls))
|
||
(t/is (= :nitrate-sso-required (:code @assigned*))))))))
|
||
|
||
(t/deftest ^:async 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)]
|
||
(await
|
||
(mock/with-mocks*
|
||
{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))
|
||
(await (async/settle))
|
||
(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 ^:async failing-organization-sso-check-is-not-reported-as-missing-access
|
||
(t/testing "the SSO check fails like a real request (later tick) and the failure stays a failure"
|
||
(reset! check-failures [])
|
||
(let [assigned* (atom nil)]
|
||
(await
|
||
(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))}
|
||
(errors/on-error (sso-required-error))
|
||
(await (async/wait-for #(= 1 (count @check-failures))
|
||
"sso check failure observed"))
|
||
(t/is (= [::test-check-failure] (mapv :type @check-failures)))
|
||
(t/is (nil? @assigned*)))))))
|
||
|
||
;; ---------------------------------------------------------------------------
|
||
;; :validation / :invalid-sso-config
|
||
;;
|
||
;; The SSO error page needs an organization-id to retry meaningfully. Promote
|
||
;; to :sso-error only when that id is present; otherwise keep :validation so
|
||
;; we do not surface a broken SSO dialog for a future code path that omits it.
|
||
;; ---------------------------------------------------------------------------
|
||
|
||
(defn- capture-async-exception
|
||
"Invoke `ptk/handle-error` while capturing the error map passed to
|
||
`rt/assign-exception` via `st/async-emit!`.
|
||
|
||
`st/async-emit!` is variadic (`[& params]`); the mock must be too,
|
||
otherwise CLJS looks up `IFn$_invoke$arity$variadic` and throws."
|
||
[error]
|
||
(let [captured (atom nil)]
|
||
(with-redefs [st/async-emit! (fn [& events]
|
||
(reset! captured (first events)))
|
||
rt/assign-exception (fn [err] err)]
|
||
(ptk/handle-error error)
|
||
@captured)))
|
||
|
||
(t/deftest invalid-sso-config-with-organization-id-promotes-to-sso-error
|
||
(t/testing "invalid-sso-config with :organization-id is shown as :sso-error"
|
||
(let [org-id #uuid "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||
assigned (capture-async-exception
|
||
{:type :validation
|
||
:code :invalid-sso-config
|
||
:organization-id org-id
|
||
:hint "missing issuer"})]
|
||
(t/is (= :sso-error (:type assigned)))
|
||
(t/is (= org-id (:organization-id assigned)))
|
||
(t/is (= :invalid-sso-config (:code assigned))))))
|
||
|
||
(t/deftest invalid-sso-config-without-organization-id-keeps-validation
|
||
(t/testing "invalid-sso-config without :organization-id must not become :sso-error"
|
||
(let [assigned (capture-async-exception
|
||
{:type :validation
|
||
:code :invalid-sso-config
|
||
:hint "missing issuer"})]
|
||
(t/is (= :validation (:type assigned)))
|
||
(t/is (nil? (:organization-id assigned)))
|
||
(t/is (= :invalid-sso-config (:code assigned))))))
|
||
|
||
(t/deftest persistence-notifications-do-not-expire-but-other-flashes-do
|
||
(doseq [[notify timeout] [[#(errors/flash :hint "Ordinary error") 5000]
|
||
[#(errors/flash :hint "Custom error" :timeout 1000) 1000]
|
||
[#(errors/flash-persistence nil) nil]]]
|
||
(let [scheduled (atom [])
|
||
events (atom [])]
|
||
(with-redefs [tm/schedule (mock/stub #(swap! scheduled conj %))
|
||
st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))]
|
||
(notify)
|
||
(t/is (empty? @events) "Keep notification delivery asynchronous")
|
||
(doseq [callback @scheduled] (callback))
|
||
(t/is (= 1 (count @events)))
|
||
(let [state (ptk/update (first @events) {})]
|
||
(t/is (= timeout (get-in state [:notification :timeout])))
|
||
(t/is (= :visible (get-in state [:notification :status]))))))))
|
||
|
||
(t/deftest ^:async persistence-notifications-include-an-error-report-download
|
||
(let [scheduled (atom [])
|
||
idle-callbacks (atom [])
|
||
events (atom [])
|
||
downloads (atom [])
|
||
revoked (atom [])
|
||
report "generated error report"
|
||
cause (ex-info "Save failed" {:type :validation})]
|
||
(await
|
||
(mock/with-mocks*
|
||
{dom/prevent-default (fn [_])
|
||
dom/trigger-download-uri (fn [& params]
|
||
(swap! downloads conj params))
|
||
errors/generate-report (fn [_ & _] report)
|
||
errors/submit-report (fn [& _])
|
||
;; `tr` is called with one and with two arguments, and its
|
||
;; two-argument arity is variadic: the stub exposes both
|
||
;; shapes so the compiled static calls resolve.
|
||
i18n/tr (fn ([key] (str key ":"))
|
||
([key & args]
|
||
(str key ":" (first args))))
|
||
st/emit! (mock/stub (fn [& emitted]
|
||
(swap! events into emitted)))
|
||
tm/schedule (mock/stub (fn [callback]
|
||
(swap! scheduled conj callback)))
|
||
tm/schedule-on-idle (mock/stub (fn [callback]
|
||
(swap! idle-callbacks conj callback)))
|
||
wapi/create-blob (mock/stub (fn [content media-type]
|
||
{:content content :media-type media-type}))
|
||
wapi/create-uri (fn [_] "blob:report")
|
||
wapi/revoke-uri (fn [uri]
|
||
(swap! revoked conj uri))}
|
||
(errors/flash-persistence cause)
|
||
(doseq [callback @scheduled] (callback))
|
||
(await (async/settle))
|
||
;; The report is emitted first and the toast right after, so the
|
||
;; toast carrying the download link is the last event collected.
|
||
(let [state (ptk/update (last @events) {})
|
||
download (get-in state [:notification :links 0])]
|
||
(t/is (= "labels.download:report.txt" (:label download)))
|
||
((:callback download) nil)
|
||
(t/is (= [["report" "text/plain" "blob:report"]] @downloads))
|
||
(doseq [callback @idle-callbacks] (callback))
|
||
(t/is (= ["blob:report"] @revoked)))))))
|
||
|
||
(t/deftest ^:async persistence-waiters-do-not-report-an-already-handled-failure
|
||
(let [reports (atom [])
|
||
rejected (atom [])
|
||
pstate (atom {:status :saving})
|
||
store (ptk/store {:state {} :on-error errors/on-error})
|
||
cause (ex-info "Save failed" {:type :network})]
|
||
(await
|
||
(mock/with-mocks*
|
||
{refs/persistence pstate
|
||
st/emit! (mock/stub (fn [& emitted]
|
||
(swap! reports into
|
||
(report-events emitted))))
|
||
;; Run the scheduled `flash` body synchronously: the whole flow
|
||
;; under test is synchronous except for the deferral.
|
||
tm/schedule (mock/stub (fn [f] (f)))}
|
||
(try
|
||
(ptk/emit! store (#'dps/persistence-failed (uuid/next) cause))
|
||
(reset! pstate (:persistence @store))
|
||
(dotimes [_ 2]
|
||
(->> (dps/wait-persisted-or-error)
|
||
(rx/subs! (fn [_] (t/is false "A failed save must still reject"))
|
||
(fn [error]
|
||
(swap! rejected conj error)
|
||
(errors/on-error error)))))
|
||
(await (async/settle))
|
||
(t/is (= 2 (count @rejected)))
|
||
(t/is (= 1 (count @reports)))
|
||
;; A standalone timeout and a later save failure are new incidents.
|
||
;; The later failure carries a distinct signature: repeating the same
|
||
;; one inside the governor window is coalesced by design.
|
||
(errors/on-error (ex-info "Save timed out" {:type :persistence :code :save-timeout}))
|
||
(await (async/settle))
|
||
(t/is (= 2 (count @reports)))
|
||
(ptk/emit! store (#'dps/persistence-failed (uuid/next)
|
||
(ex-info "Save failed again" {:type :network})))
|
||
(await (async/settle))
|
||
(t/is (= 3 (count @reports)))
|
||
(finally
|
||
(rx/dispose! store)))))))
|
||
|
||
;; ---------------------------------------------------------------------------
|
||
;; Save failures that belong to their own handler
|
||
;;
|
||
;; Some causes are not resolved by retaining the changes: the session has to
|
||
;; be renewed, the file is gone, or a different version was restored. Each
|
||
;; one is handled by the error handler for its own type.
|
||
;; ---------------------------------------------------------------------------
|
||
|
||
(t/deftest expired-session-during-save-goes-to-the-authentication-handler
|
||
(t/testing "a lost session is handled as an authentication error, not notified"
|
||
(let [assigned (atom nil)
|
||
scheduled (atom [])]
|
||
(with-redefs [rt/get-current-href (constantly workspace-href)
|
||
rt/assign-exception (fn [error] error)
|
||
st/async-emit! (fn [& events] (reset! assigned (first events)))
|
||
tm/schedule (mock/stub (fn [callback]
|
||
(swap! scheduled conj callback)))]
|
||
(errors/flash-persistence (ex-info "Session expired" {:type :authentication}))
|
||
(t/is (= :authentication (:type @assigned)))
|
||
(t/is (empty? @scheduled) "An expired session shows no save notification")))))
|
||
|
||
(t/deftest ^:async expired-organization-sso-during-save-renews-the-session
|
||
(t/testing "an SSO-guarded save failure goes back through the identity provider"
|
||
(let [events (atom [])]
|
||
(await
|
||
(mock/with-mocks*
|
||
{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/flash-persistence (ex-info "SSO required" (sso-required-error)))
|
||
(await (async/settle))
|
||
(t/is (= [::rt/nav-raw] (mapv ptk/type @events))))))))
|
||
|
||
(t/deftest a-deleted-file-during-save-shows-the-exception-page
|
||
(t/testing "a file that no longer exists shows its page, not the save notification"
|
||
(let [events (atom [])
|
||
scheduled (atom [])]
|
||
(with-redefs [rt/assign-exception (fn [error] error)
|
||
st/emit! (mock/stub (fn [& emitted]
|
||
(swap! events into emitted)))
|
||
tm/schedule (mock/stub (fn [callback]
|
||
(swap! scheduled conj callback)))]
|
||
(errors/flash-persistence (ex-info "File not found" {:type :not-found}))
|
||
(doseq [callback @scheduled] (callback))
|
||
(t/is (= [:not-found] (mapv :type @events)))))))
|
||
|
||
(t/deftest a-restored-version-during-save-reloads-the-file
|
||
(t/testing "a version restored elsewhere reloads the file instead of notifying"
|
||
(let [events (atom [])
|
||
scheduled (atom [])]
|
||
(with-redefs [st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))
|
||
tm/schedule (mock/stub (fn [callback]
|
||
(swap! scheduled conj callback)))]
|
||
(errors/flash-persistence (ex-info "A different version has been restored"
|
||
{:type :validation :code :vern-conflict}))
|
||
(t/is (= [::dw/reload-current-file] (mapv ptk/type @events)))
|
||
(t/is (empty? @scheduled) "A restored version shows no save notification")))))
|
||
|
||
(t/deftest ^:async other-validation-failures-during-save-keep-the-notification
|
||
(t/testing "a validation failure without a recovery of its own is still notified"
|
||
(let [events (atom [])
|
||
scheduled (atom [])]
|
||
(await
|
||
(mock/with-mocks*
|
||
{errors/generate-report (fn [_ & _] "generated error report")
|
||
errors/submit-report (fn [& _])
|
||
st/emit! (mock/stub (fn [& emitted]
|
||
(swap! events into emitted)))
|
||
tm/schedule (mock/stub (fn [callback]
|
||
(swap! scheduled conj callback)))}
|
||
(errors/flash-persistence (ex-info "Invalid data" {:type :validation
|
||
:code :invalid-data}))
|
||
(doseq [callback @scheduled] (callback))
|
||
(await (async/settle))
|
||
;; The report is emitted first and the toast right after: one
|
||
;; audit event plus the sticky toast carrying the report link.
|
||
(t/is (= 1 (count (report-events @events))))
|
||
(let [state (ptk/update (last @events) {})]
|
||
(t/is (nil? (get-in state [:notification :timeout])))
|
||
(t/is (some? (get-in state [:notification :links 0])))))))))
|