penpot/backend/test/backend_tests/loggers_audit_archive_task_test.clj
Pablo Alba 76e9c132ef
✨ Send more enriched events to nitrate log (#12210)
* ✨ Send more enriched events to nitrate log

* ✨ Fix mr
2026-10-09 10:58:05 +02:00

351 lines
18 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.loggers-audit-archive-task-test
(:require
[app.common.json :as json]
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.http.client :as-alias http]
[app.jobs :as jobs]
[app.setup :as-alias setup]
[backend-tests.helpers :as th]
[clojure.test :as t]
[cuerdas.core :as str]
[integrant.core :as ig]
[mockery.core :refer [with-mocks]]))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
(def ^:private archive-uri "http://nexus.example/archive")
(defn- insert-audit-row!
([profile-id name]
(insert-audit-row! profile-id name {:team-id (uuid/next)}))
([profile-id name props]
(th/db-insert! :audit-log
{:id (uuid/next)
:name name
:type "action"
:source "backend"
:profile-id profile-id
:ip-addr (db/inet "127.0.0.1")
:props (db/tjson props)
:context (db/tjson {})
:tracked-at (ct/now)
:created-at (ct/now)})))
(defn- nitrate-event-props
[nitrate-mock]
(let [[_ _ params] (:call-args @nitrate-mock)]
(:props (first (:events params)))))
(t/deftest archive-events-sends-to-nitrate-then-nexus
;; Create profile before enabling :admin-console — system nitrate
;; client is nil in tests, and team bootstrap would call it.
(let [prof (th/create-profile* 1 {:is-active true})
order (atom [])]
(with-redefs [cf/flags #{:nexus :admin-console}]
(with-mocks [nitrate-mock {:target 'app.nitrate/call
:return (fn [& _]
(swap! order conj :nitrate)
nil)}
http-mock {:target 'app.http.client/req
:return (fn [& _]
(swap! order conj :nexus)
{:status 204})}]
(insert-audit-row! (:id prof) "create-project")
(insert-audit-row! (:id prof) "create-file")
(th/run-task! :audit-log-archive {:uri archive-uri})
(t/is (:called? @nitrate-mock))
(t/is (:called? @http-mock))
(t/is (= 1 (:call-count @nitrate-mock)))
(t/is (= 1 (:call-count @http-mock)))
(t/is (= [:nitrate :nexus] @order))
(let [[_ method params] (:call-args @nitrate-mock)]
(t/is (= :ingest-audit-log method))
(t/is (= 2 (count (:events params))))
(t/is (= #{"create-project" "create-file"}
(into #{} (map :name) (:events params))))
(t/is (every? uuid? (map :id (:events params)))))
(let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])]
(t/is (= 2 (count rows))))))))
(t/deftest archive-events-filters-nitrate-event-names
(let [prof (th/create-profile* 1 {:is-active true})]
(with-redefs [cf/flags #{:nexus :admin-console}]
(with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}
http-mock {:target 'app.http.client/req :return {:status 204}}]
(insert-audit-row! (:id prof) "create-project")
(insert-audit-row! (:id prof) "create-organization-attribute")
(insert-audit-row! (:id prof) "unrelated-event")
(th/run-task! :audit-log-archive {:uri archive-uri})
(t/is (:called? @nitrate-mock))
(t/is (:called? @http-mock))
(let [[_ _ params] (:call-args @nitrate-mock)]
(t/is (= #{"create-project" "create-organization-attribute"}
(into #{} (map :name) (:events params)))))
(let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])]
(t/is (= 3 (count rows))))))))
(t/deftest archive-events-skips-nitrate-when-no-matching-names
(let [prof (th/create-profile* 1 {:is-active true})]
(with-redefs [cf/flags #{:nexus :admin-console}]
(with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}
http-mock {:target 'app.http.client/req :return {:status 204}}]
(insert-audit-row! (:id prof) "unrelated-event")
(th/run-task! :audit-log-archive {:uri archive-uri})
(t/is (false? (:called? @nitrate-mock)))
(t/is (:called? @http-mock))
(let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])]
(t/is (= 1 (count rows))))))))
(t/deftest archive-events-admin-console-only-marks-without-uri
(let [prof (th/create-profile* 1 {:is-active true})]
(with-redefs [cf/flags #{:admin-console}]
(with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}
http-mock {:target 'app.http.client/req :return {:status 204}}]
(insert-audit-row! (:id prof) "create-project")
(th/run-task! :audit-log-archive {})
(t/is (:called? @nitrate-mock))
(t/is (false? (:called? @http-mock)))
(let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])]
(t/is (= 1 (count rows))))))))
(t/deftest archive-events-admin-console-only-marks-non-allowlisted
(let [prof (th/create-profile* 1 {:is-active true})]
(with-redefs [cf/flags #{:admin-console}]
(with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}
http-mock {:target 'app.http.client/req :return {:status 204}}]
(insert-audit-row! (:id prof) "unrelated-event")
(th/run-task! :audit-log-archive {})
(t/is (false? (:called? @nitrate-mock)))
(t/is (false? (:called? @http-mock)))
(let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])]
(t/is (= 1 (count rows))))))))
(t/deftest archive-events-propagates-nitrate-error
(let [prof (th/create-profile* 1 {:is-active true})]
(with-redefs [cf/flags #{:nexus :admin-console}]
(with-mocks [nitrate-mock {:target 'app.nitrate/call
:throw (ex-info "nitrate down" {})}
http-mock {:target 'app.http.client/req :return {:status 204}}]
(insert-audit-row! (:id prof) "create-project")
(t/is (thrown? clojure.lang.ExceptionInfo
(th/run-task! :audit-log-archive {:uri archive-uri})))
(t/is (:called? @nitrate-mock))
(t/is (false? (:called? @http-mock)))
(let [rows (th/db-exec! ["select * from audit_log where archived_at is null"])]
(t/is (= 1 (count rows))))))))
(t/deftest archive-events-uses-nexus-without-admin-console
(let [prof (th/create-profile* 1 {:is-active true})]
(with-redefs [cf/flags #{:nexus}]
(with-mocks [http-mock {:target 'app.http.client/req :return {:status 204}}
nitrate-mock {:target 'app.nitrate/call :return nil}]
(insert-audit-row! (:id prof) "create-project")
(th/run-task! :audit-log-archive {:uri archive-uri})
(t/is (false? (:called? @nitrate-mock)))
(t/is (:called? @http-mock))
(let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])]
(t/is (= 1 (count rows))))))))
(t/deftest archive-events-skips-mark-when-nexus-fails
(let [prof (th/create-profile* 1 {:is-active true})]
(with-redefs [cf/flags #{:nexus}]
(with-mocks [http-mock {:target 'app.http.client/req :return {:status 500}}]
(insert-audit-row! (:id prof) "create-project")
(th/run-task! :audit-log-archive {:uri archive-uri})
(t/is (:called? @http-mock))
(let [rows (th/db-exec! ["select * from audit_log where archived_at is null"])]
(t/is (= 1 (count rows))))))))
(t/deftest archive-events-both-flags-skips-mark-when-nexus-fails
(let [prof (th/create-profile* 1 {:is-active true})]
(with-redefs [cf/flags #{:nexus :admin-console}]
(with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}
http-mock {:target 'app.http.client/req :return {:status 500}}]
(insert-audit-row! (:id prof) "create-project")
(th/run-task! :audit-log-archive {:uri archive-uri})
(t/is (:called? @nitrate-mock))
(t/is (:called? @http-mock))
(let [rows (th/db-exec! ["select * from audit_log where archived_at is null"])]
(t/is (= 1 (count rows))))))))
(t/deftest archive-events-encodes-db-rows-for-nitrate
(let [prof (th/create-profile* 1 {:is-active true})]
(with-redefs [cf/flags #{:nexus :admin-console}
cf/config (assoc cf/config
:admin-console-uri "http://ac.example/admin-console/"
:admin-console-shared-key "ac-key"
:nexus-shared-key "nexus-key")]
(let [nitrate-bodies (atom [])
shared-keys {:admin-console "ac-key" :nexus "nexus-key"}
client (ig/init-key :app.nitrate/client
{::http/client (Object.)
::setup/shared-keys shared-keys})
;; The archive job is a job-def: the handler closes over the
;; cfg it was given at init and takes a nil context, because
;; an in-process invocation has no job row to describe.
job-def (ig/init-key :app.loggers.audit.archive-task/job-def
{::db/pool (:app.db/pool th/*system*)
::setup/shared-keys shared-keys
::http/client (:app.http.client/client th/*system*)
:app.nitrate/client client})]
(with-mocks [http-mock {:target 'app.http.client/req
:return
(fn [_ req & _]
(when (str/includes? (str (:uri req)) "api/audit-log")
(swap! nitrate-bodies conj (:body req)))
{:status 204})}]
(let [team-id (uuid/next)
row (th/db-insert! :audit-log
{:id (uuid/next)
:name "create-project"
:type "action"
:source "backend"
:profile-id (:id prof)
:ip-addr (db/inet "127.0.0.1")
:props (db/tjson {:team-id team-id})
:context (db/tjson {})
:tracked-at (ct/now)
:created-at (ct/now)})]
((::jobs/handler job-def) nil {:uri archive-uri})
(t/is (= 1 (count @nitrate-bodies)))
(let [body (json/decode (first @nitrate-bodies) :key-fn json/read-kebab-key)
event (first (:events body))]
(t/is (= 1 (count (:events body))))
(t/is (= (str (:id row)) (str (:id event))))
(t/is (= "create-project" (:name event)))
(t/is (= "127.0.0.1" (:ip-addr event)))
(t/is (string? (:created-at event)))
(t/is (string? (:tracked-at event)))
(t/is (= (str team-id) (str (get-in event [:props :team-id])))))
(let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])]
(t/is (= 1 (count rows))))))))))
(t/deftest archive-events-requires-uri-when-nexus-flag-set
(with-redefs [cf/flags #{:nexus}
cf/config (dissoc cf/config :audit-log-archive-uri)]
(let [data (ex-data (try
(th/run-task! :audit-log-archive {})
(catch Throwable cause
cause)))]
(t/is (= :task-not-configured (:code data))))))
(t/deftest archive-events-enriches-team-name-and-emails
(let [actor (th/create-profile* 1 {:is-active true})
member (th/create-profile* 2 {:is-active true})
team (th/create-team* 1 {:profile-id (:id actor)})]
(with-redefs [cf/flags #{:admin-console}]
(with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}]
(insert-audit-row! (:id actor) "create-project"
{:team-id (:id team)
:profile-id (:id actor)
:user-id (:id member)
:member-id (:id member)
;; Producers store this as a string UUID.
:user-who-send-invitation (str (:id actor))})
(th/run-task! :audit-log-archive {})
(t/is (:called? @nitrate-mock))
(let [props (nitrate-event-props nitrate-mock)]
(t/is (= (:name team) (:team-name props)))
(t/is (= (:email actor) (:profile-email props)))
(t/is (= (:email member) (:user-email props)))
(t/is (= (:email member) (:member-email props)))
(t/is (= (:email actor) (:user-who-send-invitation-email props))))
(let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])]
(t/is (= 1 (count rows))))))))
(t/deftest archive-events-enriches-soft-deleted-team-and-profile
(let [actor (th/create-profile* 1 {:is-active true})
other (th/create-profile* 2 {:is-active true})
team (th/create-team* 1 {:profile-id (:id actor)})]
(th/db-update! :team {:deleted-at (ct/now)} {:id (:id team)})
(th/db-update! :profile {:deleted-at (ct/now)} {:id (:id other)})
(with-redefs [cf/flags #{:admin-console}]
(with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}]
(insert-audit-row! (:id actor) "create-project"
{:team-id (:id team)
:profile-id (:id other)})
(th/run-task! :audit-log-archive {})
(let [props (nitrate-event-props nitrate-mock)]
(t/is (= (:name team) (:team-name props)))
(t/is (= (:email other) (:profile-email props))))
(let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])]
(t/is (= 1 (count rows))))))))
(t/deftest archive-events-does-not-overwrite-existing-enrich-props
(let [actor (th/create-profile* 1 {:is-active true})
team (th/create-team* 1 {:profile-id (:id actor)})]
(with-redefs [cf/flags #{:admin-console}]
(with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}]
(insert-audit-row! (:id actor) "create-project"
{:team-id (:id team)
:team-name "already-set-name"
:profile-id (:id actor)
:profile-email "already-set@example.com"
:user-id (:id actor)
:user-email "already-user@example.com"
:member-id (:id actor)
:member-email "already-member@example.com"
:user-who-send-invitation (str (:id actor))
:user-who-send-invitation-email "already-inviter@example.com"})
(th/run-task! :audit-log-archive {})
(let [props (nitrate-event-props nitrate-mock)]
(t/is (= "already-set-name" (:team-name props)))
(t/is (= "already-set@example.com" (:profile-email props)))
(t/is (= "already-user@example.com" (:user-email props)))
(t/is (= "already-member@example.com" (:member-email props)))
(t/is (= "already-inviter@example.com"
(:user-who-send-invitation-email props))))))))
(t/deftest archive-events-enrich-skips-missing-and-non-uuid-ids
(let [actor (th/create-profile* 1 {:is-active true})]
(with-redefs [cf/flags #{:admin-console}]
(with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}]
(insert-audit-row! (:id actor) "create-project"
{:team-id (uuid/next)
:profile-id (uuid/next)
:user-id "not-a-uuid"
:member-id 42})
(th/run-task! :audit-log-archive {})
(t/is (:called? @nitrate-mock))
(let [props (nitrate-event-props nitrate-mock)]
(t/is (nil? (:team-name props)))
(t/is (nil? (:profile-email props)))
(t/is (nil? (:user-email props)))
(t/is (nil? (:member-email props)))
(t/is (= "not-a-uuid" (:user-id props)))
(t/is (= 42 (:member-id props))))
(let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])]
(t/is (= 1 (count rows))))))))
(t/deftest archive-events-forwards-export-job-not-legacy-export
(let [actor (th/create-profile* 1 {:is-active true})
team (th/create-team* 1 {:profile-id (:id actor)})]
(with-redefs [cf/flags #{:admin-console}]
(with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}]
(insert-audit-row! (:id actor) "create-export-binfile-job"
{:team-id (:id team)
:job-id (uuid/next)
:files 1
:export-type "detach-libraries"})
(insert-audit-row! (:id actor) "export-binfile"
{:file-id (uuid/next)})
(th/run-task! :audit-log-archive {})
(let [[_ _ params] (:call-args @nitrate-mock)
events (:events params)
props (:props (first events))]
(t/is (= ["create-export-binfile-job"] (mapv :name events)))
(t/is (= (:id team) (:team-id props)))
(t/is (= (:name team) (:team-name props))))
(let [rows (th/db-exec! ["select * from audit_log where archived_at is not null"])]
(t/is (= 2 (count rows))))))))