penpot/backend/test/backend_tests/rpc_audit_test.clj
Andrey Antukh be2e117f67
♻️ Rename audit-log archivers to consumers and use base Nexus URI (#12459)
* ♻️ Rename audit-log archivers to consumers

The pending-queue owners are no longer only external archives: a
local, non-archiving drain is about to join them, so "archiver" no
longer fits. Rename the column, identifiers, jobs, metrics and docs
to "consumer" in one release; migration 0157 never reached
production, so a single RENAME COLUMN is enough, with no backfill.

AI-assisted-by: muse-spark-1.3-contributor

* ♻️ Use base Nexus URI with appended ingest path

Mirror the admin-console setup: PENPOT_NEXUS_URI holds the base
URI and the consumer appends api/audit itself, instead of a full
endpoint URI. Keeps the explicit :uri job param as override and the
:task-not-configured guard when no base is set.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-19 23:25:44 +02:00

964 lines
44 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.rpc-audit-test
(:require
[app.common.pprint :as pp]
[app.common.time :as ct]
[app.common.transit :as transit]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.jobs :as-alias jobs]
[app.loggers.audit :as audit]
[app.main :as main]
[app.metrics :as mtx]
[app.metrics.definition :as-alias mdef]
[app.rpc :as-alias rpc]
[app.rpc.climit :as climit]
[app.rpc.commands.audit :as cmd-audit]
[app.util.services :as sv]
[backend-tests.helpers :as th]
[clojure.string :as str]
[clojure.test :as t]
[integrant.core :as ig]
[mockery.core :refer [with-mocks]]
[yetti.request])
(:import
io.prometheus.client.Collector$MetricFamilySamples
io.prometheus.client.CollectorRegistry
io.prometheus.client.Counter
io.prometheus.client.Counter$Child))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
(defn decode-row
[{:keys [props context] :as row}]
(cond-> row
(db/pgobject? props) (assoc :props (db/decode-transit-pgobject props))
(db/pgobject? context) (assoc :context (db/decode-transit-pgobject context))))
(def http-request
(reify
yetti.request/IRequest
(get-header [_ name]
(case name
"x-forwarded-for" "127.0.0.44"
"x-real-ip" "127.0.0.43"))))
(t/deftest push-audit-events-limit-is-keyed-by-profile
;; Regression guard. This command asks for a per-profile limit, but it used
;; to declare it as a plain keyword next to `::climit/key-fn`, metadata that
;; `climit/wrap` does not read: it takes the key only from the positional
;; `key-fn` of the vector form. That silently turned the limit into a single
;; shared one, so one stuck call ended up rejecting the audit batches of
;; every profile on the instance. Two profiles must not share a limiter.
(th/with-climit [climit {:submit-audit-events/by-profile
{:permits 1 :queue 1 :timeout 30000}}]
(let [;; The metadata declared by the command itself, that is what the test
;; has to cover.
mdata (meta (ns-resolve 'app.rpc.commands.audit 'sm$push-audit-events))
gate (promise)
entered (promise)
calls (atom 0)
handler (fn [_ _]
(when (= 1 (swap! calls inc))
(deliver entered true)
@gate)
:ok)
wrapped (climit/wrap {::rpc/climit climit} handler mdata)
call (fn [profile-id]
(wrapped {::rpc/climit climit} {::rpc/profile-id profile-id}))
pid-a (uuid/next)
pid-b (uuid/next)
holder (future (call pid-a))]
;; The first call stays inside the handler holding the only permit.
(t/is (true? (deref entered 2000 false)))
;; Another call from the SAME profile shares the limiter, so it waits.
(let [same (future (call pid-a))]
(t/is (= :waiting (deref same 300 :waiting))))
;; A different profile owns a different limiter and is served right away.
;; With the shared limiter (the bug) this one waits too and times out.
(t/is (= :ok (deref (future (call pid-b)) 2000 :timeout)))
(deliver gate true)
(t/is (= :ok (deref holder 2000 :timeout))))))
(t/deftest prepare-context-initiator-is-plain-string
;; The initiator must always be a plain string, never a keyword: shared-key
;; authenticated callers (exporter, admin-console) arrive as keywords on
;; :app.http/auth-key-id and transit would persist them as "~:exporter".
(let [base {:headers {"x-forwarded-for" "127.0.0.44"}}]
(t/is (= "app" (:initiator (audit/prepare-context-from-request base))))
(t/is (= "exporter"
(:initiator (audit/prepare-context-from-request
(assoc base :app.http/auth-key-id :exporter)))))
(t/is (= "admin-console"
(:initiator (audit/prepare-context-from-request
(assoc base :app.http/auth-key-id :admin-console)))))
(t/is (string? (:initiator (audit/prepare-context-from-request
(assoc base :app.http/auth-key-id :nexus)))))))
(t/deftest push-events-1
(with-redefs [app.config/flags #{:audit-log :nexus}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
proj-id (:default-project-id prof)
params {::th/type :push-audit-events
::rpc/profile-id (:id prof)
:events [{:name "navigate"
:props {:project-id (str proj-id)
:team-id (str team-id)
:route "dashboard-files"}
:context {:engine "blink"}
:profile-id (:id prof)
:timestamp (ct/now)
:type "action"}]}
params (with-meta params
{:app.http/request http-request})
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(t/is (nil? (:result out)))
(let [[row :as rows] (->> (th/db-exec! ["select * from audit_log"])
(mapv decode-row))]
;; (pp/pprint rows)
(t/is (= 1 (count rows)))
(t/is (= (:id prof) (:profile-id row)))
(t/is (= "navigate" (:name row)))
(t/is (= "frontend" (:source row)))
;; The row is born pending for the enabled consumer.
(t/is (= 1 (count (th/db-exec! ["select * from audit_log_pending"]))))))))
(t/deftest push-events-2
(with-redefs [app.config/flags #{:audit-log}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
proj-id (:default-project-id prof)
params {::th/type :push-audit-events
::rpc/profile-id (:id prof)
:events [{:name "navigate"
:props {:project-id (str proj-id)
:team-id (str team-id)
:route "dashboard-files"}
:context {:engine "blink"}
:profile-id uuid/zero
:timestamp (ct/now)
:type "action"}]}
params (with-meta params
{:app.http/request http-request})
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(t/is (nil? (:result out)))
(let [[row :as rows] (->> (th/db-exec! ["select * from audit_log"])
(mapv decode-row))]
;; (pp/pprint rows)
(t/is (= 1 (count rows)))
(t/is (= (:id prof) (:profile-id row)))
(t/is (= "navigate" (:name row)))
(t/is (= "frontend" (:source row)))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; TELEMETRY MODE (frontend ingest)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(t/deftest push-events-telemetry-mode-stores-anonymized-row
;; When telemetry is enabled and audit-log is NOT, frontend events
;; must be stored with source="telemetry", empty props, zeroed ip,
;; and context filtered to safe keys only.
(with-redefs [cf/flags #{:telemetry}]
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
proj-id (:default-project-id prof)
params {::th/type :push-audit-events
::rpc/profile-id (:id prof)
:events [{:name "navigate"
:props {:project-id (str proj-id)
:team-id (str team-id)
:route "dashboard-files"}
:context {:browser "Chrome"
:browser-version "120.0"
:os "Linux"
:version "2.0.0"
:session "should-be-stripped"
:external-session-id "also-stripped"
:initiator "app"}
:timestamp (ct/now)
:type "action"}]}
params (with-meta params
{:app.http/request http-request})
out (th/command! params)]
(t/is (nil? (:error out)))
(t/is (nil? (:result out)))
(let [[row :as rows] (->> (th/db-exec! ["select * from audit_log"])
(mapv decode-row))]
(t/is (= 1 (count rows)))
;; source is telemetry:frontend, not frontend
(t/is (= "telemetry:frontend" (:source row)))
;; profile-id preserved
(t/is (= (:id prof) (:profile-id row)))
;; event name preserved
(t/is (= "navigate" (:name row)))
;; navigate events keep route and team-id; other keys stripped
(t/is (= {:route "dashboard-files"
:team-id (str team-id)}
(:props row)))
;; ip zeroed
(t/is (= "0.0.0.0" (str (:ip-addr row))))
;; timestamps truncated to day precision
(let [day-now (ct/truncate (ct/now) :days)]
(t/is (= day-now (:created-at row)))
(t/is (= day-now (:tracked-at row))))
;; context only contains safe keys
(let [ctx (:context row)]
(t/is (contains? ctx :browser))
(t/is (= "Chrome" (:browser ctx)))
(t/is (contains? ctx :os))
(t/is (= "Linux" (:os ctx)))
;; session-linking keys stripped
(t/is (not (contains? ctx :session)))
(t/is (not (contains? ctx :external-session-id))))))))
(t/deftest push-events-both-flags-creates-two-rows
;; When both :audit-log and :telemetry flags are active, two rows
;; should be stored: one full audit entry and one telemetry entry.
;; The profile is created before enabling :admin-console: team
;; bootstrap calls the real nitrate client, which is unconfigured
;; in tests.
(let [prof (th/create-profile* 1 {:is-active true})]
(with-redefs [cf/flags #{:audit-log :telemetry :nexus :admin-console}]
(let [params {::th/type :push-audit-events
::rpc/profile-id (:id prof)
:events [{:name "navigate"
:props {:route "dashboard"}
:context {:browser "Chrome"
:version "2.0.0"
:initiator "app"}
:timestamp (ct/now)
:type "action"}]}
params (with-meta params
{:app.http/request http-request})
out (th/command! params)]
(t/is (nil? (:error out)))
(let [[row1 row2 :as rows] (->> (th/db-exec! ["select * from audit_log order by source"])
(mapv decode-row))]
(t/is (= 2 (count rows)))
;; First row: full audit-log entry
(t/is (= "frontend" (:source row1)))
(t/is (contains? (:props row1) :route))
(t/is (not= "0.0.0.0" (str (:ip-addr row1))))
;; Second row: telemetry entry
(t/is (= "telemetry:frontend" (:source row2)))
(t/is (= "0.0.0.0" (str (:ip-addr row2))))
(let [day-now (ct/truncate (ct/now) :days)]
(t/is (= day-now (:created-at row2)))
(t/is (= day-now (:tracked-at row2))))
;; Both rows are born pending for the enabled consumers.
(t/is (= 4 (count (th/db-exec! ["select * from audit_log_pending"])))))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; BACKEND PROCESS-EVENT PATH (RPC commands)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(t/deftest backend-process-event-only-audit-log
(with-redefs [cf/flags #{:audit-log}]
(let [prof (th/create-profile* 1 {:is-active true})
event {:id (uuid/next)
:type "action"
:name "test-cmd"
:profile-id (:id prof)
:props {:full-key "full-val"}
:context {:version "2.0.0" :initiator "app"}
:tracked-at (ct/now)
:created-at (ct/now)
:source "backend"}]
(audit/submit* th/*system* event)
(let [[row :as rows] (->> (th/db-exec! ["select * from audit_log"])
(mapv decode-row))]
(t/is (= 1 (count rows)))
(t/is (= "backend" (:source row)))
(t/is (= "full-val" (get-in row [:props :full-key])))
(t/is (not= "0.0.0.0" (str (:ip-addr row))))))))
(t/deftest backend-process-event-only-telemetry
(with-redefs [cf/flags #{:telemetry}]
(let [prof (th/create-profile* 1 {:is-active true})
event {:id (uuid/next)
:type "action"
:name "test-cmd"
:profile-id (:id prof)
:props {:full-key "full-val"}
:context {:version "2.0.0" :initiator "app"}
:tracked-at (ct/now)
:created-at (ct/now)
:source "backend"}]
(audit/submit* th/*system* event)
(let [[row :as rows] (->> (th/db-exec! ["select * from audit_log"])
(mapv decode-row))]
(t/is (= 1 (count rows)))
(t/is (= "telemetry:backend" (:source row)))
(t/is (= "0.0.0.0" (str (:ip-addr row))))))))
(t/deftest backend-process-event-both-flags-creates-two-rows
;; When both :audit-log and :telemetry are active, the backend
;; process-event must store two rows: one full audit entry and one
;; telemetry entry.
(with-redefs [cf/flags #{:audit-log :telemetry}]
(let [prof (th/create-profile* 1 {:is-active true})
event {:id (uuid/next)
:type "action"
:name "test-cmd"
:profile-id (:id prof)
:props {:keep-me "important"}
:context {:version "2.0.0" :initiator "app"}
:tracked-at (ct/now)
:created-at (ct/now)
:source "backend"}]
(audit/submit* th/*system* event)
(let [[row1 row2 :as rows] (->> (th/db-exec! ["select * from audit_log order by source"])
(mapv decode-row))]
(t/is (= 2 (count rows)))
;; First row: full audit-log entry
(t/is (= "backend" (:source row1)))
(t/is (= "important" (get-in row1 [:props :keep-me])))
(t/is (not= "0.0.0.0" (str (:ip-addr row1))))
;; Second row: telemetry entry
(t/is (= "telemetry:backend" (:source row2)))
(t/is (= "0.0.0.0" (str (:ip-addr row2))))
(let [day-now (ct/truncate (ct/now) :days)]
(t/is (= day-now (:created-at row2)))
(t/is (= day-now (:tracked-at row2))))))))
(t/deftest backend-process-event-forwards-id-to-webhook-task
;; The `webhook/trigger` row links back to its origin event through
;; props `:event-id`, so `process-event` must forward the stored row
;; id to the `:process-webhook-event` worker task. Regression test:
;; the id used to be generated inside `append-audit-entry` without
;; ever reaching the worker params, so `:event-id` always landed nil.
(with-mocks [submit-mock {:target 'app.jobs/submit :return nil}]
(with-redefs [cf/flags #{:audit-log :webhooks}]
(let [prof (th/create-profile* 1 {:is-active true})
event {:type "action"
:name "delete-project"
:profile-id (:id prof)
:props {:team-id (:default-team-id prof)}
:context {:version "2.0.0" :initiator "app"}
:tracked-at (ct/now)
:created-at (ct/now)
:source "backend"
:app.loggers.webhooks/event? true}]
(audit/submit* th/*system* event)
(let [[row :as rows] (->> (th/db-exec! ["select * from audit_log"])
(mapv decode-row))]
(t/is (= 1 (count rows)))
(t/is (= 1 (:call-count @submit-mock)))
(let [params (-> @submit-mock :call-args-list first second ::jobs/params)
task-event (transit/decode-str (:event-blob params))]
(t/is (some? (:id task-event)))
(t/is (= (:id row) (:id task-event)))))))))
(t/deftest backend-process-event-telemetry-row-gets-own-id
;; When both `:audit-log` and `:telemetry` are active, the shadow
;; telemetry row must not reuse the full row id (shared PK), while
;; the webhook task still carries the full row id.
(with-mocks [submit-mock {:target 'app.jobs/submit :return nil}]
(with-redefs [cf/flags #{:audit-log :telemetry :webhooks}]
(let [prof (th/create-profile* 1 {:is-active true})
event {:type "action"
:name "delete-project"
:profile-id (:id prof)
:props {:team-id (:default-team-id prof)}
:context {:version "2.0.0" :initiator "app"}
:tracked-at (ct/now)
:created-at (ct/now)
:source "backend"
:app.loggers.webhooks/event? true}]
(audit/submit* th/*system* event)
(let [[row1 row2 :as rows] (->> (th/db-exec! ["select * from audit_log order by source"])
(mapv decode-row))]
(t/is (= 2 (count rows)))
(t/is (not= (:id row1) (:id row2)))
(let [params (-> @submit-mock :call-args-list first second ::jobs/params)
task-event (transit/decode-str (:event-blob params))]
(t/is (= (:id (first (filter #(= "backend" (:source %)) rows)))
(:id task-event)))))))))
(t/deftest push-events-disabled-when-no-flags-and-no-telemetry
;; When neither audit-log nor telemetry is enabled, no rows should
;; be stored.
(with-redefs [cf/flags #{}]
(let [prof (th/create-profile* 1 {:is-active true})
params {::th/type :push-audit-events
::rpc/profile-id (:id prof)
:events [{:name "navigate"
:props {:route "dashboard"}
:timestamp (ct/now)
:type "action"}]}
params (with-meta params
{:app.http/request http-request})
out (th/command! params)]
(t/is (nil? (:error out)))
(t/is (= 0 (count (th/db-exec! ["select * from audit_log"])))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; GET-ENVIRONMENT-DATA
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(t/deftest get-environment-data-returns-selfhost-by-default
(with-redefs [cf/flags #{:audit-log}]
(let [out (th/command! {::th/type :get-environment-data})]
(t/is (th/success? out))
(t/is (= "selfhost" (-> out :result :deployment))))))
(t/deftest get-environment-data-returns-saas-when-configured
(with-redefs [cf/flags #{:telemetry}]
(binding [cf/config (assoc cf/config :is-saas true)]
(let [out (th/command! {::th/type :get-environment-data})]
(t/is (th/success? out))
(t/is (= "saas" (-> out :result :deployment)))))))
(t/deftest get-environment-data-only-exposes-event-flags
(with-redefs [cf/flags #{:audit-log :telemetry :graph :nrepl-server}]
(let [out (th/command! {::th/type :get-environment-data})]
(t/is (th/success? out))
(t/is (= #{:audit-log :telemetry} (-> out :result :flags))))))
(t/deftest get-environment-data-returns-empty-flags-when-none-enabled
(with-redefs [cf/flags #{:graph}]
(let [out (th/command! {::th/type :get-environment-data})]
(t/is (th/success? out))
(t/is (= #{} (-> out :result :flags))))))
(t/deftest get-enabled-flags-still-returns-flat-set
(with-redefs [cf/flags #{:audit-log :telemetry :graph}]
(let [out (th/command! {::th/type :get-enabled-flags})]
(t/is (th/success? out))
(t/is (= #{:audit-log :telemetry} (:result out))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PURE HELPER UNIT TESTS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(t/deftest extract-utm-params-utm
;; UTM params are namespaced under :penpot
(let [result (audit/extract-utm-params {:utm_source "google"
:utm_medium "cpc"
:utm_campaign "spring"
:other "ignored"})]
(t/is (= "google" (:penpot/utm-source result)))
(t/is (= "cpc" (:penpot/utm-medium result)))
(t/is (= "spring" (:penpot/utm-campaign result)))
(t/is (not (contains? result :other)))))
(t/deftest extract-utm-params-mtm
;; MTM params are also namespaced under :penpot
(let [result (audit/extract-utm-params {:mtm_source "newsletter"
:mtm_medium "email"})]
(t/is (= "newsletter" (:penpot/mtm-source result)))
(t/is (= "email" (:penpot/mtm-medium result)))))
(t/deftest extract-utm-params-empty
(t/is (= {} (audit/extract-utm-params {})))
(t/is (= {} (audit/extract-utm-params {:foo "bar" :baz 42}))))
(t/deftest profile->props-selects-and-merges
;; Selects profile-props keys and merges with (:props profile)
(let [profile {:id (uuid/next)
:fullname "John"
:email "john@example.com"
:is-active true
:lang "en"
:deleted-field "gone"
:props {:custom-key "custom-val"
:newsletter-updates true}}
result (audit/profile->props profile)]
;; Selected keys from profile
(t/is (= "John" (:fullname result)))
(t/is (= "john@example.com" (:email result)))
(t/is (true? (:is-active result)))
(t/is (= "en" (:lang result)))
;; Merged from (:props profile)
(t/is (= "custom-val" (:custom-key result)))
(t/is (true? (:newsletter-updates result)))
;; Keys not in profile-props are excluded
(t/is (not (contains? result :deleted-field)))))
(t/deftest profile->props-removes-nils
(let [profile {:id (uuid/next) :fullname nil :email "a@b.com"}
result (audit/profile->props profile)]
(t/is (not (contains? result :fullname)))
(t/is (= "a@b.com" (:email result)))))
(t/deftest clean-props-removes-reserved
;; Reserved props (:session-id, :password, :old-password, :token) are stripped
(let [props {:name "test"
:session-id "sess-123"
:password "secret"
:old-password "old-secret"
:token "tok-456"
:valid-key "kept"}
result (audit/clean-props props)]
(t/is (= "test" (:name result)))
(t/is (= "kept" (:valid-key result)))
(t/is (not (contains? result :session-id)))
(t/is (not (contains? result :password)))
(t/is (not (contains? result :old-password)))
(t/is (not (contains? result :token)))))
(t/deftest clean-props-removes-qualified-keys
;; Qualified keywords (namespaced) are stripped
(let [props {:simple "kept"
::namespaced "stripped"
:app.rpc/also-stripped true}
result (audit/clean-props props)]
(t/is (= "kept" (:simple result)))
(t/is (not (contains? result ::namespaced)))
(t/is (not (contains? result :app.rpc/also-stripped)))))
(t/deftest clean-props-removes-nils
(let [props {:a nil :b "val" :c nil}
result (audit/clean-props props)]
(t/is (= "val" (:b result)))
(t/is (not (contains? result :a)))
(t/is (not (contains? result :c)))))
(t/deftest get-external-session-id-valid
(let [request (reify yetti.request/IRequest
(get-header [_ name]
(case name "x-external-session-id" "abc-123")))]
(t/is (= "abc-123" (audit/get-external-session-id request)))))
(t/deftest get-external-session-id-nil-when-missing
(let [request (reify yetti.request/IRequest
(get-header [_ _] nil))]
(t/is (nil? (audit/get-external-session-id request)))))
(t/deftest get-external-session-id-nil-when-null-string
(let [request (reify yetti.request/IRequest
(get-header [_ name]
(case name "x-external-session-id" "null")))]
(t/is (nil? (audit/get-external-session-id request)))))
(t/deftest get-external-session-id-nil-when-blank
(let [request (reify yetti.request/IRequest
(get-header [_ name]
(case name "x-external-session-id" " ")))]
(t/is (nil? (audit/get-external-session-id request)))))
(t/deftest get-external-session-id-nil-when-too-long
(let [long-id (apply str (repeat 300 "x"))
request (reify yetti.request/IRequest
(get-header [_ name]
(case name "x-external-session-id" long-id)))]
(t/is (nil? (audit/get-external-session-id request)))))
(t/deftest get-client-user-agent-valid
(let [request (reify yetti.request/IRequest
(get-header [_ name]
(case name "user-agent" "Mozilla/5.0 (Test)")))]
(t/is (= "Mozilla/5.0 (Test)" (audit/get-client-user-agent request)))))
(t/deftest get-client-user-agent-nil-when-missing
(let [request (reify yetti.request/IRequest
(get-header [_ _] nil))]
(t/is (nil? (audit/get-client-user-agent request)))))
(t/deftest get-client-user-agent-truncates-long
(let [long-ua (apply str (repeat 600 "x"))
request (reify yetti.request/IRequest
(get-header [_ name]
(case name "user-agent" long-ua)))]
(t/is (<= (count (audit/get-client-user-agent request)) 500))))
(t/deftest get-client-event-origin-valid
(let [get-client-event-origin (ns-resolve 'app.loggers.audit 'get-client-event-origin)
request (reify yetti.request/IRequest
(get-header [_ name]
(case name "x-event-origin" "workspace")))]
(t/is (= "workspace" (get-client-event-origin request)))))
(t/deftest get-client-event-origin-nil-when-null
(let [get-client-event-origin (ns-resolve 'app.loggers.audit 'get-client-event-origin)
request (reify yetti.request/IRequest
(get-header [_ name]
(case name "x-event-origin" "null")))]
(t/is (nil? (get-client-event-origin request)))))
(t/deftest get-client-event-origin-nil-when-blank
(let [get-client-event-origin (ns-resolve 'app.loggers.audit 'get-client-event-origin)
request (reify yetti.request/IRequest
(get-header [_ name]
(case name "x-event-origin" " ")))]
(t/is (nil? (get-client-event-origin request)))))
(t/deftest get-client-event-origin-truncates-long
(let [get-client-event-origin (ns-resolve 'app.loggers.audit 'get-client-event-origin)
long-origin (apply str (repeat 300 "a"))
request (reify yetti.request/IRequest
(get-header [_ name]
(case name "x-event-origin" long-origin)))]
(t/is (<= (count (get-client-event-origin request)) 200))))
(t/deftest get-client-version-valid
(let [get-client-version (ns-resolve 'app.loggers.audit 'get-client-version)
request (reify yetti.request/IRequest
(get-header [_ name]
(case name "x-frontend-version" "2.0.0")))]
(t/is (= "2.0.0" (get-client-version request)))))
(t/deftest get-client-version-nil-when-null
(let [get-client-version (ns-resolve 'app.loggers.audit 'get-client-version)
request (reify yetti.request/IRequest
(get-header [_ name]
(case name "x-frontend-version" "null")))]
(t/is (nil? (get-client-version request)))))
(t/deftest get-client-version-nil-when-blank
(let [get-client-version (ns-resolve 'app.loggers.audit 'get-client-version)
request (reify yetti.request/IRequest
(get-header [_ name]
(case name "x-frontend-version" " ")))]
(t/is (nil? (get-client-version request)))))
(t/deftest get-client-initiator-from-x-client
(let [request (fn [client]
(reify yetti.request/IRequest
(get-header [_ name]
(case name "x-client" client))))]
(t/is (= "app" (audit/get-client-initiator (request nil))))
(t/is (= "app" (audit/get-client-initiator (request "penpot-frontend/2.19.0"))))
(t/is (= "admin-console" (audit/get-client-initiator (request "penpot-admin-console/2.19.0"))))
(t/is (= "admin-console" (audit/get-client-initiator (request "penpot-nitrate/2.19.0"))))
(t/is (= "admin-console" (audit/get-client-initiator (request "Penpot-Admin-Console/2.19.0"))))
(t/is (= "app" (audit/get-client-initiator (request "something-else/1.0"))))))
(t/deftest push-events-assigns-initiator-from-x-client
(with-redefs [app.config/flags #{:audit-log}]
(let [prof (th/create-profile* 1 {:is-active true})
params {::th/type :push-audit-events
::rpc/profile-id (:id prof)
:events [{:name "navigate"
:props {:route "dashboard"}
:context {:engine "blink"
:initiator "fake-client-value"}
:type "action"
:timestamp (ct/now)}]}
params (with-meta params
{:app.http/request
(th/make-dummy-request
:headers {"x-client" "penpot-admin-console/2.19.0"})})
out (th/command! params)]
(t/is (nil? (:error out)))
(let [[row] (->> (th/db-exec! ["select * from audit_log"])
(mapv decode-row))]
;; The server overwrites whatever initiator the client sends.
(t/is (= "admin-console" (-> row :context :initiator)))))))
(t/deftest push-events-initiator-defaults-to-app
(with-redefs [app.config/flags #{:audit-log}]
(let [prof (th/create-profile* 1 {:is-active true})
params {::th/type :push-audit-events
::rpc/profile-id (:id prof)
:events [{:name "navigate"
:props {:route "dashboard"}
:context {:engine "blink"}
:type "action"
:timestamp (ct/now)}]}
params (with-meta params
{:app.http/request (th/make-dummy-request)})
out (th/command! params)]
(t/is (nil? (:error out)))
(let [[row] (->> (th/db-exec! ["select * from audit_log"])
(mapv decode-row))]
(t/is (= "app" (-> row :context :initiator)))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; INSERT DEFAULTS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(t/deftest insert-only-runs-with-audit-log-flag
;; insert must be a no-op when :audit-log flag is not set
(with-redefs [app.config/flags #{:telemetry}]
(audit/insert th/*system* {:name "test" :type "action"})
(t/is (= 0 (count (th/db-exec! ["select * from audit_log"]))))))
(t/deftest insert-sets-defaults
;; insert must set defaults and persist when :audit-log is set
(with-redefs [app.config/flags #{:audit-log}]
(audit/insert th/*system* {:name "test-action" :type "action"})
(let [[row] (->> (th/db-exec! ["select * from audit_log"])
(mapv decode-row))]
(t/is (some? row))
(t/is (= "test-action" (:name row)))
(t/is (= "action" (:type row)))
(t/is (= "backend" (:source row)))
(t/is (some? (:id row)))
(t/is (some? (:created-at row)))
(t/is (some? (:tracked-at row)))
(t/is (= {} (:props row)))
(t/is (= {} (:context row))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PREPARE-RPC-EVENT PROFILE-ID RESOLUTION
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- prepare-event
"Call prepare-rpc-event with a bare request, returning the built event."
([caller result]
(prepare-event caller "test-cmd" result))
([caller cmd-name result]
(let [mdata {::sv/name cmd-name}
params {::rpc/profile-id caller
::rpc/request-id (uuid/next)
::rpc/request-at (ct/now)}
mock-req (reify
yetti.request/IRequest
(get-header [_ _] nil)
(remote-addr [_] "127.0.0.1"))
params (with-meta params {:app.http/request mock-req})]
;; Production always resolves commands with `::module` set ("main" or
;; "management"): the event name (and the read exclusion) depends on it.
(audit/prepare-rpc-event (assoc th/*system* ::rpc/module "main")
mdata params result))))
(t/deftest prepare-rpc-event-ignores-profile-id-from-result
;; An audit event belongs to the caller, never to whatever `:profile-id`
;; the response carries. `get-error-report` returns the report with its
;; decoded content merged in, and that content can hold the profile that
;; owned the report, so honoring it attributed the call to somebody who
;; never made it.
(let [caller (th/create-profile* 1 {:is-active true})]
(t/is (= (:id caller)
(:profile-id (prepare-event (:id caller)
{:profile-id "33601240-a00b-11ea-ba1b-c554cc60e361"
:some-data "value"}))))
;; an unparseable one must not break the event either
(t/is (= (:id caller)
(:profile-id (prepare-event (:id caller)
{:profile-id "not-a-valid-uuid"}))))))
(t/deftest prepare-rpc-event-uses-metadata-profile-id
;; `::audit/profile-id` metadata is the only sanctioned override: it is
;; how commands that authenticate somebody else (login, verify-token)
;; attribute the event to the right profile.
(let [caller (th/create-profile* 1 {:is-active true})
target (th/create-profile* 2 {:is-active true})
result (with-meta {:some-data "value"}
{::audit/profile-id (:id target)})]
(t/is (= (:id target)
(:profile-id (prepare-event (:id caller) result))))))
(t/deftest prepare-rpc-event-falls-back-to-zero-for-anonymous-callers
;; With no metadata and no authenticated caller there is nobody to
;; attribute the event to, so it lands on the zero uuid.
(t/is (= uuid/zero
(:profile-id (prepare-event nil {:some-data "value"})))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PREPARE-RPC-EVENT TEAM-ID ENRICHMENT
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(t/deftest prepare-rpc-event-enriches-team-id-from-project-id
;; An event that carries a project id resolves its team locally, so the
;; downstream consumers do not need to join production tables.
(let [profile (th/create-profile* 1 {:is-active true})
team (th/create-team* 10 {:profile-id (:id profile)})
project (th/create-project* 10 {:profile-id (:id profile)
:team-id (:id team)})
result (with-meta {} {::audit/props {:project-id (:id project)}})
event (prepare-event (:id profile) result)]
(t/is (= (:id team) (get-in event [:props :team-id])))))
(t/deftest prepare-rpc-event-enriches-team-id-from-file-id
(let [profile (th/create-profile* 1 {:is-active true})
team (th/create-team* 11 {:profile-id (:id profile)})
project (th/create-project* 11 {:profile-id (:id profile)
:team-id (:id team)})
file (th/create-file* 11 {:profile-id (:id profile)
:project-id (:id project)})
result (with-meta {} {::audit/props {:file-id (:id file)}})
event (prepare-event (:id profile) result)]
(t/is (= (:id team) (get-in event [:props :team-id])))))
(t/deftest prepare-rpc-event-keeps-existing-team-id
;; The resolver never overwrites a team id the command already set.
(let [profile (th/create-profile* 1 {:is-active true})
team (th/create-team* 12 {:profile-id (:id profile)})
project (th/create-project* 12 {:profile-id (:id profile)
:team-id (:id team)})
result (with-meta {} {::audit/props {:team-id (:id team)
:project-id (:id project)}})
event (prepare-event (:id profile) result)]
(t/is (= (:id team) (get-in event [:props :team-id])))))
(t/deftest prepare-rpc-event-does-not-resolve-without-project-or-file
;; Read commands put the entity id under `:id`; enriching those would add a
;; query to every read event, so the resolver must leave them untouched.
(let [profile (th/create-profile* 1 {:is-active true})
result (with-meta {} {::audit/props {:id (uuid/next)}})
event (prepare-event (:id profile) result)]
(t/is (not (contains? (:props event) :team-id)))))
(t/deftest prepare-rpc-event-resolves-default-team-id
;; The resolver adds the team id whenever it can resolve it, including the
;; personal (default) team.
(let [profile (th/create-profile* 1 {:is-active true})
result (with-meta {} {::audit/props {:project-id (:default-project-id profile)}})
event (prepare-event (:id profile) result)]
(t/is (= (:default-team-id profile) (get-in event [:props :team-id])))))
(t/deftest prepare-rpc-event-skips-team-id-resolution-for-reads
;; Read events carry no consumer state: resolving their team would add a
;; query to every read, so `get-*` names (plus the known non-get reads)
;; are left untouched even when they carry a project or file id.
(let [profile (th/create-profile* 1 {:is-active true})
team (th/create-team* 13 {:profile-id (:id profile)})
project (th/create-project* 13 {:profile-id (:id profile)
:team-id (:id team)})
file (th/create-file* 13 {:profile-id (:id profile)
:project-id (:id project)})]
(doseq [cmd-name ["get-file-fragment" "get-comments" "has-file-libraries"]
props [{:file-id (:id file)} {:project-id (:id project)}]]
(let [result (with-meta {} {::audit/props props})
event (prepare-event (:id profile) cmd-name result)]
(t/is (not (contains? (:props event) :team-id))
(str cmd-name " with " (keys props) " must not resolve team-id"))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PREPARE-RPC-EVENT PROFILE-ID COERCION
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(t/deftest prepare-rpc-event-coerces-string-metadata-profile-id
;; `::audit/profile-id` is set by hand in a dozen commands, and some of
;; them read the value from token claims or other stringly-typed sources.
;; The audit schema demands a uuid, and `submit*` swallows the validation
;; error, so an unconverted string would drop the event on the floor.
(let [caller (th/create-profile* 1 {:is-active true})
target "33601240-a00b-11ea-ba1b-c554cc60e361"
result (with-meta {:some-data "value"}
{::audit/profile-id target})]
(t/is (= #uuid "33601240-a00b-11ea-ba1b-c554cc60e361"
(:profile-id (prepare-event (:id caller) result))))
(t/is (uuid? (:profile-id (prepare-event (:id caller) result))))))
(t/deftest prepare-rpc-event-discards-unusable-metadata-profile-id
;; An override that cannot be turned into a uuid is dropped, not honoured
;; and not propagated: the event falls back to the caller, which is always
;; a valid uuid, instead of failing the schema check and losing the row.
(let [caller (th/create-profile* 1 {:is-active true})]
(doseq [bad ["not-a-valid-uuid" "" " " 42 {} [] :whatever nil false]]
(let [result (with-meta {:some-data "value"}
(cond-> {::audit/profile-id bad}
(nil? bad) (dissoc ::audit/profile-id)))]
(t/is (= (:id caller)
(:profile-id (prepare-event (:id caller) result)))
(str "override " (pr-str bad) " must fall back to the caller"))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; INGEST METRICS (bulk frontend path)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; handle-events resolves its metrics from the cfg (same pattern as
;; the jobs substrate), so these tests drive it directly with an
;; isolated registry instead of the shared test-system one.
(defn- make-ingest-metrics
[]
(ig/init-key :app.metrics/metrics
{:default (select-keys main/default-metrics [:audit-ingest-events])}))
(defn- ingest-counter
[metrics kind outcome]
(let [collector (mtx/get-collector metrics :audit-ingest-events)
instance (::mdef/instance collector)
child (.labels ^Counter instance (into-array String [kind outcome]))]
(.get ^Counter$Child child)))
(defn- counter-label-sets
"Exact label sets present for the ingest counter, read off the
registry so phantom series cannot hide. Matches the family by
prefix (Prometheus strips the `_total` suffix from the family
name) and only counts `_total` samples, not `_created` ones."
[metrics]
(let [full (get main/default-metrics :audit-ingest-events)
base (str/replace (::mdef/name full) #"_total$" "")]
(->> (enumeration-seq
(.metricFamilySamples ^CollectorRegistry (mtx/get-registry metrics)))
(filter (fn [^Collector$MetricFamilySamples family]
(str/starts-with? (.-name family) base)))
(mapcat (fn [^Collector$MetricFamilySamples family]
(.samples family)))
(filter (fn [sample] (str/ends-with? (.-name sample) "_total")))
(into #{} (map (fn [sample] (vec (.-labelValues sample))))))))
(defn- push-params
[profile-id]
(with-meta {::rpc/profile-id profile-id
::rpc/request-at (ct/now)
:events [{:name "navigate" :type "action"
:props {:route "dashboard"}
:timestamp (ct/now)}]}
;; A map request (like th/command! builds): the reify IRequest
;; stub only serves the IP headers and blows up on x-client.
{:app.http/request (th/make-dummy-request)}))
(t/deftest push-handle-events-records-inserted
(let [prof (th/create-profile* 1 {:is-active true})
metrics (make-ingest-metrics)]
(with-redefs [cf/flags #{:audit-log :telemetry :nexus :admin-console}]
(@#'cmd-audit/handle-events {::db/pool th/*pool* ::mtx/metrics metrics}
(push-params (:id prof)))
(t/is (= 1.0 (ingest-counter metrics "full" "inserted")))
(t/is (= 1.0 (ingest-counter metrics "shadow" "inserted")))
(t/is (= 2 (count (th/db-exec! ["select * from audit_log"]))))
(t/is (= 4 (count (th/db-exec! ["select * from audit_log_pending"])))))))
(t/deftest push-handle-events-records-failed-on-error
(let [prof (th/create-profile* 1 {:is-active true})
metrics (make-ingest-metrics)]
(with-redefs [cf/flags #{:audit-log :telemetry :nexus :admin-console}]
(with-mocks [insert-mock {:target 'app.db/insert-many!
:throw (ex-info "boom" {})}]
(t/is (thrown? clojure.lang.ExceptionInfo
(@#'cmd-audit/handle-events {::db/pool th/*pool* ::mtx/metrics metrics}
(push-params (:id prof)))))
(t/is (= 1.0 (ingest-counter metrics "full" "failed")))
(t/is (= 1.0 (ingest-counter metrics "shadow" "failed")))
(t/is (= 0 (count (th/db-exec! ["select * from audit_log"]))))
(t/is (= 0 (count (th/db-exec! ["select * from audit_log_pending"]))))))))
(t/deftest push-handle-events-single-face-emits-no-empty-series
;; With only :audit-log on, exactly one ingest series exists: the
;; pos? guard in record-ingested emits no phantom shadow series.
;; (No send flags either, so R3 leaves the row pending-less.)
(let [prof (th/create-profile* 1 {:is-active true})
metrics (make-ingest-metrics)]
(with-redefs [cf/flags #{:audit-log}]
(@#'cmd-audit/handle-events {::db/pool th/*pool* ::mtx/metrics metrics}
(push-params (:id prof)))
(t/is (= 1.0 (ingest-counter metrics "full" "inserted")))
(t/is (= #{["full" "inserted"]} (counter-label-sets metrics)))
(t/is (= 1 (count (th/db-exec! ["select * from audit_log"]))))
(t/is (= 0 (count (th/db-exec! ["select * from audit_log_pending"])))))))