penpot/backend/test/backend_tests/loggers_audit_archive_task_test.clj
Pablo Alba 03e24d18ce
✨ Dual-send audit log archive to Nitrate and Nexus (#12037)
Run the audit archive cron when :nexus or :admin-console is on. Ship
allowlisted events to Admin Console first (with row ids for
idempotency), then the full chunk to Nexus when :nexus is set. Mark
archived_at for the whole chunk on success, including nitrate-only
mode. Rename the gate flag from :audit-log-archive to :nexus.

AI-assisted-by: Composer

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 17:17:11 +02:00

228 lines
12 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.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]
(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 {:team-id (uuid/next)})
:context (db/tjson {})
:tracked-at (ct/now)
:created-at (ct/now)}))
(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) "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"] (mapv :name (: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-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})
handler (ig/init-key :app.loggers.audit.archive-task/handler
{::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)})]
(handler {:props {: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))))))