mirror of
https://github.com/penpot/penpot.git
synced 2026-11-09 19:41:34 +00:00
The cached path of the media-storage-bytes-per-team quota reads the team_storage_usage counter, and that counter only stays true while somebody marks the teams dirty. That somebody is the events consumer: with its flag off nobody refreshes the counter and the quota reads a frozen value, so it lets uploads over the limit pass without a word. The failure is permissive, which is the one this work came to fix. The cached path needs the :event-consumer flag too, next to :storage-usage and :audit-log. Without it the quota falls back to the live sum: slow on big teams, but true. AI-assisted-by: minimax-m3.1-flash
1092 lines
55 KiB
Clojure
1092 lines
55 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.tasks-team-storage-test
|
|
"Cached per-team media storage usage fed by the audit-log
|
|
storage-usage consumer: selective queueing, dirty marking, bounded
|
|
recompute and quota reads."
|
|
(:require
|
|
[app.common.logging :as l]
|
|
[app.common.time :as ct]
|
|
[app.common.uuid :as uuid]
|
|
[app.config :as cf]
|
|
[app.db :as db]
|
|
[app.jobs :as jobs]
|
|
[app.loggers.audit :as audit]
|
|
[app.loggers.audit.consumers :as consumer]
|
|
[app.metrics :as mtx]
|
|
[app.rpc :as-alias rpc]
|
|
[app.rpc.commands.audit :as cmd-audit]
|
|
[app.rpc.quotes :as quotes]
|
|
[app.srepl.main :as srepl]
|
|
[app.system :as sys]
|
|
[app.tasks.team-storage :as team-storage]
|
|
[backend-tests.helpers :as th]
|
|
[clojure.test :as t]
|
|
[mockery.core :refer [with-mocks]]))
|
|
|
|
(t/use-fixtures :once th/state-init)
|
|
(t/use-fixtures :each th/database-reset)
|
|
|
|
(def ^:private test-flags
|
|
"Quote checks run (:quotes), events are stored (:audit-log), the
|
|
events consumer queues them (:event-consumer) and the team-storage
|
|
reaction runs (:storage-usage)."
|
|
#{:quotes :audit-log :event-consumer :storage-usage})
|
|
|
|
(def ^:private real-audit-submit
|
|
"The unstubbed audit submit: helpers.clj replaces it globally per
|
|
test, so an end-to-end producer test rebinds it back."
|
|
@#'audit/submit)
|
|
|
|
(defn- events-pendings
|
|
"The pendings of the events consumer (id 3): it is not selective, it
|
|
takes the whole stream."
|
|
[]
|
|
(th/db-exec! ["SELECT audit_log_id, consumer FROM audit_log_pending WHERE consumer = 3"]))
|
|
|
|
(defn- usage-row
|
|
[team-id]
|
|
(th/db-exec-one! ["SELECT team_id, total_bytes, dirty_at, updated_at FROM team_storage_usage WHERE team_id = ?" team-id]))
|
|
|
|
(defn- append-row
|
|
([name props]
|
|
(append-row name props "backend"))
|
|
([name props source]
|
|
(#'audit/append-audit-entry
|
|
th/*system*
|
|
{:id (uuid/next)
|
|
:name name
|
|
:type "action"
|
|
:source source
|
|
:profile-id uuid/zero
|
|
:ip-addr "127.0.0.1"
|
|
:props props
|
|
:context {}
|
|
:tracked-at (ct/now)
|
|
:created-at (ct/now)})))
|
|
|
|
(t/deftest events-queueing-takes-every-event
|
|
;; The events consumer is not selective: the whole stream queues and
|
|
;; each reaction decides what concerns it.
|
|
(with-redefs [cf/flags #{:audit-log :event-consumer}]
|
|
(append-row "upload-file-media-object" {})
|
|
(append-row "navigate" {})
|
|
(append-row "delete-file" {})
|
|
(t/is (= 3 (count (events-pendings))))))
|
|
|
|
(t/deftest storage-usage-reaction-ignores-hard-delete-objects
|
|
;; Physical purges cannot change the live total (they only remove rows
|
|
;; the SUM already excludes), so the reaction does not claim them:
|
|
;; this also keeps purged teams from ever hitting the usage-table
|
|
;; foreign key.
|
|
(let [prof (th/create-profile* 62 {:is-active true})
|
|
team (:default-team-id prof)]
|
|
(with-redefs [cf/flags test-flags]
|
|
(append-row "hard-delete-objects" {:team-ids [team]})
|
|
(th/run-task! :audit-log-consumer-events {})
|
|
(t/is (nil? (:dirty-at (usage-row team)))))))
|
|
|
|
(t/deftest events-queueing-skips-telemetry-shadows
|
|
;; A telemetry shadow keeps the name of the full row, so queueing it
|
|
;; would dispatch the same event twice.
|
|
(with-redefs [cf/flags #{:audit-log :telemetry :event-consumer}]
|
|
(append-row "upload-file-media-object" {} "backend")
|
|
(append-row "upload-file-media-object" {} "telemetry:backend")
|
|
(t/is (= 1 (count (events-pendings))))))
|
|
|
|
(t/deftest storage-usage-reaction-covers-every-listed-name
|
|
;; Pins the reaction contract for every name in
|
|
;; team-storage-event-names: each one marks the teams its key map
|
|
;; resolves, using the real prop shape. It fails when a name is
|
|
;; added without its case; the producers themselves are pinned by the
|
|
;; end-to-end tests (delete-file, delete-font-variant, moves,
|
|
;; file-gc), which go through real command output.
|
|
(let [prof (th/create-profile* 71 {:is-active true})
|
|
team (:default-team-id prof)
|
|
team-b (:id (th/create-team* 71 {:profile-id (:id prof)}))
|
|
proj (th/create-project* 71 {:profile-id (:id prof) :team-id team})
|
|
file (th/create-file* 71 {:profile-id (:id prof) :project-id (:id proj) :is-shared false})
|
|
cases [["upload-file-media-object" {:team-id team :file-id (:id file)}]
|
|
["create-file-media-object-from-url" {:team-id team}]
|
|
["clone-file-media-object" {:team-id team :file-id (:id file)}]
|
|
["assemble-file-media-object" {:team-id team}]
|
|
["create-file" {:team-id team :project-id (:id proj)}]
|
|
["create-font-variant" {:team-id team}]
|
|
["delete-font" {:team-id team}]
|
|
["delete-font-variant" {:team-id team}]
|
|
["delete-file" {:team-id team :file-id (:id file)}]
|
|
["delete-object-with-cascade" {:team-ids [team]
|
|
:cascade {:team-ids [team-b]
|
|
:project-ids []
|
|
:file-ids []}}]
|
|
["move-files" {:source-team-id team :dest-team-id team-b}]
|
|
["move-project" {:source-team-id team-b :dest-team-id team}]
|
|
["permanently-delete-team-files" {:team-id team}]
|
|
["restore-deleted-team-files" {:team-id team-b}]
|
|
["restore-file" {:team-id team}]
|
|
["restore-profile" {:team-ids [team team-b]}]
|
|
["restore-project" {:team-id team-b}]
|
|
["restore-team" {:team-id team}]
|
|
["file-gc" {:team-id team :file-id (:id file)}]]]
|
|
(t/is (= team-storage/team-storage-event-names (set (map first cases))))
|
|
(with-redefs [cf/flags test-flags]
|
|
(doseq [[nm props] cases]
|
|
(append-row nm props))
|
|
;; Negatives: the reaction does not claim them.
|
|
(append-row "hard-delete-objects" {:team-ids [team]})
|
|
(append-row "navigate" {:route "dashboard"})
|
|
(th/run-task! :audit-log-consumer-events {})
|
|
(t/is (some? (:dirty-at (usage-row team))))
|
|
(t/is (some? (:dirty-at (usage-row team-b)))))))
|
|
|
|
(t/deftest storage-usage-mid-recompute-shrink-re-marks-after
|
|
;; Production flow: a shrink landing mid-recompute is queued but not
|
|
;; yet drained, so the upsert clears only the stale mark; the later
|
|
;; drain re-marks from the pending and the next tick recomputes.
|
|
;; Nothing is lost without a trace: the pending is the durable
|
|
;; record.
|
|
(let [prof (th/create-profile* 74 {:is-active true})
|
|
team (:default-team-id prof)
|
|
real @#'quotes/get-live-media-storage-bytes]
|
|
(with-redefs [cf/flags test-flags]
|
|
(append-row "upload-file-media-object" {:team-id team})
|
|
(th/run-task! :audit-log-consumer-events {})
|
|
(with-redefs [quotes/get-live-media-storage-bytes
|
|
(fn [conn team-id]
|
|
(append-row "delete-file" {:team-id team})
|
|
(real conn team-id))]
|
|
(th/run-task! :schedule-team-storage {})
|
|
(th/run-pending-jobs))
|
|
;; The stale mark is gone but the mid-run pending survived it.
|
|
(t/is (nil? (:dirty-at (usage-row team))))
|
|
(t/is (= 1 (count (events-pendings))))
|
|
;; The drain re-marks from the pending; the next tick clears it.
|
|
(th/run-task! :audit-log-consumer-events {})
|
|
(t/is (some? (:dirty-at (usage-row team))))
|
|
(th/run-task! :schedule-team-storage {})
|
|
(th/run-pending-jobs)
|
|
(t/is (nil? (:dirty-at (usage-row team)))))))
|
|
|
|
(t/deftest storage-usage-upsert-keeps-newer-dirty-mark
|
|
;; Mechanical: a dirty_at newer than the worker read survives the
|
|
;; upsert (CASE branch), so a mark the SUM could not include is
|
|
;; recomputed by the next tick instead of silently cleared.
|
|
(let [prof (th/create-profile* 78 {:is-active true})
|
|
team (:default-team-id prof)]
|
|
(with-redefs [cf/flags test-flags]
|
|
(append-row "upload-file-media-object" {:team-id team})
|
|
(th/run-task! :audit-log-consumer-events {})
|
|
(th/db-update! :team-storage-usage
|
|
{:dirty-at (ct/in-future {:hours 1})}
|
|
{:team-id team})
|
|
(let [live (quotes/get-live-media-storage-bytes th/*system* team)]
|
|
(t/is (== live (th/run-task! :recompute-team-storage {:team-id team})))
|
|
(t/is (== live (:total-bytes (usage-row team))))
|
|
(t/is (some? (:dirty-at (usage-row team))))))))
|
|
|
|
(t/deftest events-queueing-needs-flag
|
|
(with-redefs [cf/flags #{:audit-log}]
|
|
(append-row "upload-file-media-object" {})
|
|
(t/is (= 0 (count (events-pendings))))))
|
|
|
|
(t/deftest queue-pendings-batches-past-the-threshold
|
|
;; Above `single-insert-max-ids` the helper must chunk instead of one
|
|
;; statement: same rows, no parameter-limit risk.
|
|
(let [prof (th/create-profile* 80 {:is-active true})
|
|
ids (mapv (fn [_]
|
|
(let [row (th/db-insert! :audit-log
|
|
{:id (uuid/next)
|
|
:name "create-file"
|
|
:type "action"
|
|
:source "backend"
|
|
:profile-id (:id prof)
|
|
:ip-addr (db/inet "127.0.0.1")
|
|
:props (db/tjson {})
|
|
:context (db/tjson {})
|
|
:tracked-at (ct/now)
|
|
:created-at (ct/now)})]
|
|
(:id row)))
|
|
(range 5))]
|
|
(with-redefs [consumer/single-insert-max-ids 2]
|
|
(consumer/queue-pendings th/*system* ids consumer/consumer-nexus))
|
|
(t/is (= 5 (count (th/db-exec! ["SELECT * FROM audit_log_pending WHERE consumer = 1"]))))))
|
|
|
|
(t/deftest team-storage-ensure-skips-a-purged-team
|
|
;; A team purged between the quota read and the insert must not turn
|
|
;; the fail-open path into an FK error.
|
|
(let [team (uuid/next)]
|
|
;; A bare team row: no projects to cascade, so the purge is clean.
|
|
(th/db-insert! :team {:id team
|
|
:name "purged"
|
|
:is-default false})
|
|
(th/db-force-delete :team {:id team})
|
|
;; No FK error and no orphan row: the insert simply matches nothing.
|
|
(#'quotes/ensure-team-storage-usage th/*pool* team)
|
|
(t/is (nil? (usage-row team)))))
|
|
|
|
(t/deftest team-storage-ensure-writes-only-when-missing
|
|
;; The quota ensure is a pure read when the row exists: no write
|
|
;; (no WAL, no speculative lock) on the upload hot path.
|
|
(let [prof (th/create-profile* 72 {:is-active true})
|
|
team (:default-team-id prof)
|
|
past (ct/in-past {:days 1})]
|
|
;; Born dirty on first call.
|
|
(#'quotes/ensure-team-storage-usage th/*pool* team)
|
|
(t/is (some? (:dirty-at (usage-row team))))
|
|
;; Simulate a recomputed row, then prove a second call moves
|
|
;; nothing: neither dirty_at nor updated_at.
|
|
(th/db-update! :team-storage-usage {:dirty-at nil :updated-at past} {:team-id team})
|
|
(#'quotes/ensure-team-storage-usage th/*pool* team)
|
|
(let [row (usage-row team)]
|
|
(t/is (nil? (:dirty-at row)))
|
|
(t/is (= (inst-ms past) (inst-ms (:updated-at row)))))))
|
|
|
|
(defn- admin-console-pendings
|
|
[]
|
|
(th/db-exec! ["SELECT audit_log_id, consumer FROM audit_log_pending WHERE consumer = 2"]))
|
|
|
|
(t/deftest admin-console-queueing-skips-telemetry-shadows
|
|
;; A shadow keeps the event name, so queueing it would ingest the
|
|
;; same allowlisted event twice (once full, once anonymized).
|
|
(with-redefs [cf/flags #{:audit-log :telemetry :admin-console}]
|
|
(append-row "create-file" {:team-id (uuid/next)} "backend")
|
|
(append-row "create-file" {:team-id (uuid/next)} "telemetry:backend")
|
|
(t/is (= 1 (count (admin-console-pendings))))))
|
|
|
|
(t/deftest admin-console-bulk-ingest-skips-telemetry-shadows
|
|
;; Same rule on the bulk ingest path.
|
|
(let [prof (th/create-profile* 79 {:is-active true})]
|
|
(with-redefs [cf/flags #{:audit-log :telemetry :admin-console}]
|
|
(@#'cmd-audit/handle-events
|
|
{::db/pool th/*pool* ::mtx/metrics (th/metrics-stub)}
|
|
(with-meta {::rpc/profile-id (:id prof)
|
|
::rpc/request-at (ct/now)
|
|
:events [{:name "create-file" :type "action"
|
|
:props {} :timestamp (ct/now)}]}
|
|
{:app.http/request (th/make-dummy-request)}))
|
|
(t/is (= 1 (count (admin-console-pendings)))))))
|
|
|
|
(t/deftest admin-console-queueing-is-selective
|
|
;; Only allowlisted event names queue admin-console pendings.
|
|
(with-redefs [cf/flags #{:audit-log :admin-console}]
|
|
(append-row "create-file" {:team-id (uuid/next)})
|
|
(append-row "navigate" {:route "dashboard"})
|
|
(t/is (= 1 (count (admin-console-pendings))))))
|
|
|
|
(t/deftest admin-console-bulk-ingest-queueing-is-selective
|
|
;; The bulk frontend ingest queues admin-console pendings only for
|
|
;; allowlisted names (this keeps its browser events working).
|
|
(let [prof (th/create-profile* 1 {:is-active true})]
|
|
(with-redefs [cf/flags #{:audit-log :admin-console}]
|
|
(@#'cmd-audit/handle-events
|
|
{::db/pool th/*pool* ::mtx/metrics (th/metrics-stub)}
|
|
(with-meta {::rpc/profile-id (:id prof)
|
|
::rpc/request-at (ct/now)
|
|
:events [{:name "create-file" :type "action"
|
|
:props {} :timestamp (ct/now)}
|
|
{:name "navigate" :type "action"
|
|
:props {} :timestamp (ct/now)}]}
|
|
{:app.http/request (th/make-dummy-request)}))
|
|
(t/is (= 1 (count (admin-console-pendings)))))))
|
|
|
|
(t/deftest admin-console-bulk-ingest-queueing-needs-flag
|
|
;; Disabled consumers queue nothing, even for allowlisted names and
|
|
;; even for nexus (the default call): no consumer means no pendings.
|
|
(let [prof (th/create-profile* 1 {:is-active true})]
|
|
(with-redefs [cf/flags #{:audit-log}]
|
|
(@#'cmd-audit/handle-events
|
|
{::db/pool th/*pool* ::mtx/metrics (th/metrics-stub)}
|
|
(with-meta {::rpc/profile-id (:id prof)
|
|
::rpc/request-at (ct/now)
|
|
:events [{:name "create-file" :type "action"
|
|
:props {} :timestamp (ct/now)}
|
|
{:name "navigate" :type "action"
|
|
:props {} :timestamp (ct/now)}]}
|
|
{:app.http/request (th/make-dummy-request)}))
|
|
(t/is (= 0 (count (admin-console-pendings))))
|
|
(t/is (= 0 (count (th/db-exec! ["select * from audit_log_pending"])))))))
|
|
|
|
(t/deftest storage-usage-chunk-warns-on-a-nil-team-id
|
|
;; A key present with a nil value is the producer-failed shape: it
|
|
;; must not pass as "carries its keys".
|
|
(let [records (atom [])
|
|
_ (add-watch l/log-record ::capture
|
|
(fn [_ _ _ record] (swap! records conj record)))]
|
|
(with-redefs [cf/flags test-flags]
|
|
(append-row "delete-file" {:team-id nil})
|
|
(try
|
|
(th/run-task! :audit-log-consumer-events {})
|
|
(finally
|
|
(remove-watch l/log-record ::capture)))
|
|
(t/is (= 0 (count (events-pendings))))
|
|
(t/is (let [deadline (+ (System/currentTimeMillis) 5000)]
|
|
(loop []
|
|
(if (some #(= "storage-usage chunk resolved no team ids"
|
|
(get (::l/props %) :hint))
|
|
@records)
|
|
true
|
|
(when (< (System/currentTimeMillis) deadline)
|
|
(Thread/sleep 50)
|
|
(recur)))))))))
|
|
|
|
(t/deftest storage-usage-chunk-does-not-warn-on-explicit-empty-teams
|
|
;; A profile that owns no team sends :team-ids [] on purpose: that is
|
|
;; a fact, not a producer that lost its keys.
|
|
(let [records (atom [])
|
|
_ (add-watch l/log-record ::capture
|
|
(fn [_ _ _ record] (swap! records conj record)))]
|
|
(with-redefs [cf/flags test-flags]
|
|
(append-row "restore-profile" {:id (uuid/next) :team-ids []})
|
|
(try
|
|
(th/run-task! :audit-log-consumer-events {})
|
|
(finally
|
|
(remove-watch l/log-record ::capture)))
|
|
(t/is (= 0 (count (events-pendings))))
|
|
(t/is (not-any? #(= "storage-usage chunk resolved no team ids"
|
|
(get (::l/props %) :hint))
|
|
@records)))))
|
|
|
|
(t/deftest events-drain-does-not-wait-on-a-locked-audit-log-row
|
|
;; The drain locks its own pendings, not the audit_log rows it only
|
|
;; reads: another transaction holding one of those rows must not stop
|
|
;; it. The row it cannot lock is left for the orphan sweep.
|
|
(let [prof (th/create-profile* 85 {:is-active true})
|
|
team-a (:default-team-id prof)
|
|
team-b (:id (th/create-team* 85 {:profile-id (:id prof)}))]
|
|
(with-redefs [cf/flags test-flags]
|
|
(let [locked (append-row "upload-file-media-object" {:team-id team-a})]
|
|
(append-row "upload-file-media-object" {:team-id team-b})
|
|
;; Make the locked row the oldest, so it is the one the drain
|
|
;; would pick first.
|
|
(th/db-update! :audit-log {:created-at (ct/in-past {:minutes 1})} {:id (:id locked)})
|
|
(with-open [conn (db/open th/*system*)]
|
|
(db/exec! conn ["BEGIN"])
|
|
(db/exec! conn ["SELECT id FROM audit_log WHERE id = ? FOR UPDATE" (:id locked)])
|
|
(try
|
|
(th/run-task! :audit-log-consumer-events {})
|
|
(finally
|
|
(db/exec! conn ["ROLLBACK"])))
|
|
;; Both events were dispatched: the drain never waits for the
|
|
;; row another transaction holds.
|
|
(t/is (= 0 (count (events-pendings))))
|
|
(t/is (some? (:dirty-at (usage-row team-a))))
|
|
(t/is (some? (:dirty-at (usage-row team-b)))))))))
|
|
|
|
(t/deftest events-drain-waits-for-the-run-in-flight
|
|
;; The events have to be executed in order, like an event log: a
|
|
;; second run (another backend, or a retry overlapping the previous
|
|
;; one) waits for the pendings of the run in flight instead of taking
|
|
;; the newer events while the older ones are still being dispatched.
|
|
(let [prof (th/create-profile* 86 {:is-active true})
|
|
team (:default-team-id prof)]
|
|
(with-redefs [cf/flags test-flags]
|
|
(append-row "upload-file-media-object" {:team-id team})
|
|
(with-open [conn (db/open th/*system*)]
|
|
(db/exec! conn ["BEGIN"])
|
|
;; Lock the pendings of the events consumer, as a run in flight
|
|
;; holds them.
|
|
(db/exec! conn ["SELECT audit_log_id FROM audit_log_pending
|
|
WHERE consumer = 3 FOR UPDATE"])
|
|
;; The future needs the system: the dynamic var is not
|
|
;; inherited by the thread.
|
|
(let [system th/*system*
|
|
job (future (binding [th/*system* system]
|
|
(th/run-task! :audit-log-consumer-events {})))]
|
|
;; The run is blocked on the pending: it dispatched nothing.
|
|
(Thread/sleep 500)
|
|
(t/is (nil? (:dirty-at (usage-row team))))
|
|
(db/exec! conn ["COMMIT"])
|
|
;; The run finishes once the pending is free: it did not
|
|
;; time out waiting for it.
|
|
(t/is (not= :timeout (deref job 10000 :timeout)))
|
|
(t/is (some? (:dirty-at (usage-row team)))))))))
|
|
|
|
(t/deftest events-run-summary-names-its-work
|
|
;; The run summary must not say "events archived": this consumer
|
|
;; dispatches the stream and has no destination.
|
|
(t/is (= "events dispatched"
|
|
(#'consumer/consumer-summary consumer/consumer-events)))
|
|
(t/is (= "events archived"
|
|
(#'consumer/consumer-summary consumer/consumer-nexus))))
|
|
|
|
(t/deftest storage-usage-chunk-warns-on-empty-resolution
|
|
;; A relevant event that resolves no team is a producer that stopped
|
|
;; carrying team keys: the chunk logs it instead of going stale in
|
|
;; silence. (Only the keyless event is appended: any valid team
|
|
;; would mark and silence the warn by design. Logging is async, so
|
|
;; the assert polls like objects-gc-test does.)
|
|
(let [records (atom [])
|
|
_ (add-watch l/log-record ::capture
|
|
(fn [_ _ _ record] (swap! records conj record)))]
|
|
(with-redefs [cf/flags test-flags]
|
|
(append-row "delete-file" {})
|
|
(try
|
|
(th/run-task! :audit-log-consumer-events {})
|
|
(finally
|
|
(remove-watch l/log-record ::capture)))
|
|
(t/is (= 0 (count (events-pendings))))
|
|
(t/is (let [deadline (+ (System/currentTimeMillis) 5000)]
|
|
(loop []
|
|
(if (some #(= "storage-usage chunk resolved no team ids"
|
|
(get (::l/props %) :hint))
|
|
@records)
|
|
true
|
|
(when (< (System/currentTimeMillis) deadline)
|
|
(Thread/sleep 50)
|
|
(recur)))))))))
|
|
|
|
(t/deftest storage-usage-real-delete-file-queues-pending
|
|
;; As end-to-end as the harness allows (wrap-audit is decided at
|
|
;; boot without audit flags, so the two wrap lines run here by
|
|
;; hand): the real command result feeds the real event pipeline, no
|
|
;; hand-built event. A drift in the event name or the producer
|
|
;; props fails here instead of in production.
|
|
(let [prof (th/create-profile* 77 {:is-active true})
|
|
team (:default-team-id prof)
|
|
proj (th/create-project* 77 {:profile-id (:id prof) :team-id team})
|
|
file (th/create-file* 77 {:profile-id (:id prof) :project-id (:id proj) :is-shared false})
|
|
[mdata _] (get-in th/*system* [:app.rpc/methods :delete-file])]
|
|
(with-redefs [cf/flags test-flags
|
|
audit/submit real-audit-submit]
|
|
(let [result (th/command-raw! {::th/type :delete-file
|
|
::rpc/profile-id (:id prof)
|
|
:id (:id file)})
|
|
params (-> {::rpc/profile-id (:id prof)
|
|
::rpc/request-at (ct/now)}
|
|
(with-meta {:app.http/request (th/make-dummy-request)}))
|
|
event (audit/prepare-rpc-event {::rpc/module "main" ::rpc/type "command"}
|
|
mdata params result)]
|
|
(t/is (= "delete-file" (:name event)))
|
|
(t/is (some? (:team-id (:props event))))
|
|
(audit/submit th/*system* event))
|
|
;; Only the command event: the cascade job has not run yet.
|
|
(t/is (= 1 (count (events-pendings)))))))
|
|
|
|
(t/deftest storage-usage-real-delete-font-variant-queues-pending
|
|
;; A producer whose props do NOT carry :team-id (only the request
|
|
;; params do): a future change to ::audit/replace-props would break
|
|
;; it silently, so it is pinned end to end.
|
|
(let [prof (th/create-profile* 81 {:is-active true})
|
|
team (:default-team-id prof)
|
|
variant (th/db-insert! :team-font-variant
|
|
{:id (uuid/next)
|
|
:team-id team
|
|
:profile-id (:id prof)
|
|
:font-id (uuid/next)
|
|
:font-family "somefont"
|
|
:font-weight 400
|
|
:font-style "normal"})
|
|
[mdata _] (get-in th/*system* [:app.rpc/methods :delete-font-variant])]
|
|
(with-redefs [cf/flags test-flags
|
|
audit/submit real-audit-submit]
|
|
(let [result (th/command-raw! {::th/type :delete-font-variant
|
|
::rpc/profile-id (:id prof)
|
|
:team-id team
|
|
:id (:id variant)})
|
|
params (-> {::rpc/profile-id (:id prof)
|
|
:team-id team
|
|
::rpc/request-at (ct/now)}
|
|
(with-meta {:app.http/request (th/make-dummy-request)}))
|
|
event (audit/prepare-rpc-event {::rpc/module "main" ::rpc/type "command"}
|
|
mdata params result)]
|
|
(t/is (= "delete-font-variant" (:name event)))
|
|
(t/is (= team (:team-id (:props event))))
|
|
(audit/submit th/*system* event))
|
|
(t/is (= 1 (count (events-pendings)))))))
|
|
|
|
(t/deftest storage-usage-drain-skips-invalid-team-id
|
|
;; One bad row cannot wedge the queue: the event is logged and
|
|
;; skipped, the chunk completes, and the valid teams still mark.
|
|
(let [prof (th/create-profile* 73 {:is-active true})
|
|
team (:default-team-id prof)]
|
|
(with-redefs [cf/flags test-flags]
|
|
(append-row "upload-file-media-object" {:team-id "not-a-uuid"})
|
|
(append-row "upload-file-media-object" {:team-id team})
|
|
(t/is (= 2 (count (events-pendings))))
|
|
(th/run-task! :audit-log-consumer-events {})
|
|
(t/is (= 0 (count (events-pendings))))
|
|
(t/is (some? (:dirty-at (usage-row team)))))))
|
|
|
|
(t/deftest events-append-queues-the-whole-stream
|
|
;; The queue of the events consumer is not selective: the selectivity
|
|
;; lives in the reaction, which only claims the names it knows.
|
|
(with-redefs [cf/flags test-flags]
|
|
(append-row "upload-file-media-object" {:team-id (uuid/next)})
|
|
(append-row "navigate" {:route "dashboard"})
|
|
(t/is (= 2 (count (events-pendings))))))
|
|
|
|
(t/deftest storage-usage-drain-and-recompute
|
|
(let [prof (th/create-profile* 1)
|
|
team-id (:default-team-id prof)
|
|
proj (th/create-project* 1 {:profile-id (:id prof) :team-id team-id})
|
|
file (th/create-file* 1 {:profile-id (:id prof) :project-id (:id proj) :is-shared false})
|
|
mfile {:filename "sample.jpg"
|
|
:path (th/tempfile "backend_tests/test_files/sample.jpg")
|
|
:mtype "image/jpeg"
|
|
:size 312043}]
|
|
(with-redefs [cf/flags test-flags]
|
|
(let [out (th/multipart-command! {::th/type :upload-file-media-object
|
|
::rpc/profile-id (:id prof)
|
|
:file-id (:id file)
|
|
:is-local true
|
|
:name "testfile"
|
|
:content mfile})]
|
|
(t/is (nil? (:error out))))
|
|
(append-row "upload-file-media-object" {:team-id team-id :file-id (:id file)})
|
|
(th/run-task! :audit-log-consumer-events {})
|
|
(let [row (usage-row team-id)]
|
|
(t/is (some? row))
|
|
(t/is (some? (:dirty-at row))))
|
|
(t/is (= 1 (th/run-task! :schedule-team-storage {})))
|
|
(th/run-pending-jobs)
|
|
(let [row (usage-row team-id)
|
|
live (quotes/get-live-media-storage-bytes th/*system* team-id)]
|
|
(t/is (pos? (:total-bytes row)))
|
|
(t/is (== live (:total-bytes row)))
|
|
(t/is (nil? (:dirty-at row))))
|
|
(with-mocks [mock {:target 'app.config/get
|
|
:return (th/config-get-mock {:quotes-media-storage-bytes-per-team 100})}]
|
|
(let [out (th/multipart-command! {::th/type :upload-file-media-object
|
|
::rpc/profile-id (:id prof)
|
|
:file-id (:id file)
|
|
:is-local true
|
|
:name "testfile2"
|
|
:content mfile})]
|
|
(t/is (some? (:error out)))
|
|
(t/is (= :max-quote-reached (:code (ex-data (:error out))))))))))
|
|
|
|
(t/deftest media-quota-fails-open-when-the-row-is-missing
|
|
;; The count query must always answer one row: a team whose usage row
|
|
;; was never created (or a purged one) has to pass, not crash.
|
|
(let [prof (th/create-profile* 84 {:is-active true})
|
|
ghost (uuid/next)]
|
|
(with-redefs [cf/flags test-flags]
|
|
(t/is (nil? (quotes/check! th/*system*
|
|
{::quotes/id ::quotes/media-storage-bytes-per-team
|
|
::quotes/profile-id (:id prof)
|
|
::quotes/team-id ghost
|
|
::quotes/incr 1})))
|
|
(t/is (nil? (usage-row ghost))))))
|
|
|
|
(t/deftest media-quota-reads-the-counter-not-the-live-sum
|
|
;; The cached total is the truth: with the counter at 0 the upload
|
|
;; passes even though the live SUM is far above the limit, and the
|
|
;; live query is never called on the upload path.
|
|
(let [prof (th/create-profile* 82 {:is-active true})
|
|
team (:default-team-id prof)
|
|
proj (th/create-project* 82 {:profile-id (:id prof) :team-id team})
|
|
file (th/create-file* 82 {:profile-id (:id prof) :project-id (:id proj) :is-shared false})
|
|
mfile {:filename "sample.jpg"
|
|
:path (th/tempfile "backend_tests/test_files/sample.jpg")
|
|
:mtype "image/jpeg"
|
|
:size 312043}]
|
|
(with-redefs [cf/flags test-flags]
|
|
(let [out (th/multipart-command! {::th/type :upload-file-media-object
|
|
::rpc/profile-id (:id prof)
|
|
:file-id (:id file)
|
|
:is-local true
|
|
:name "testfile"
|
|
:content mfile})]
|
|
(t/is (nil? (:error out))))
|
|
(th/db-update! :team-storage-usage {:total-bytes 0 :dirty-at nil} {:team-id team})
|
|
;; Limit above one upload but below two: the live SUM (already
|
|
;; ~312 KB) would reject the second one, the cached 0 must not.
|
|
(with-mocks [mock {:target 'app.config/get
|
|
:return (th/config-get-mock {:quotes-media-storage-bytes-per-team 400000})}]
|
|
(with-redefs [quotes/get-live-media-storage-bytes
|
|
(fn [& _] (throw (ex-info "live sum must not run" {})))]
|
|
(let [out (th/multipart-command! {::th/type :upload-file-media-object
|
|
::rpc/profile-id (:id prof)
|
|
:file-id (:id file)
|
|
:is-local true
|
|
:name "testfile2"
|
|
:content mfile})]
|
|
(t/is (nil? (:error out)))))))))
|
|
|
|
(t/deftest media-quota-falls-back-to-the-live-sum-without-the-events-consumer
|
|
;; The cached counter is only true while somebody marks the teams
|
|
;; dirty, and that is the events consumer. Without it the counter is
|
|
;; frozen, so the quota has to read the live sum: slow, but true.
|
|
;; A frozen counter (no row at all) with a live sum above the limit
|
|
;; must reject the upload.
|
|
(doseq [[i flags] (map-indexed vector [#{:quotes :audit-log :storage-usage}
|
|
#{:quotes :audit-log}])]
|
|
(let [prof (th/create-profile* (+ 82 i) {:is-active true})
|
|
team (:default-team-id prof)
|
|
proj (th/create-project* (+ 82 i) {:profile-id (:id prof) :team-id team})
|
|
file (th/create-file* (+ 82 i) {:profile-id (:id prof) :project-id (:id proj) :is-shared false})
|
|
mfile {:filename "sample.jpg"
|
|
:path (th/tempfile "backend_tests/test_files/sample.jpg")
|
|
:mtype "image/jpeg"
|
|
:size 312043}]
|
|
(with-redefs [cf/flags flags]
|
|
(let [out (th/multipart-command! {::th/type :upload-file-media-object
|
|
::rpc/profile-id (:id prof)
|
|
:file-id (:id file)
|
|
:is-local true
|
|
:name "testfile"
|
|
:content mfile})]
|
|
(t/is (nil? (:error out))))
|
|
;; The live path never creates the counter row: nobody is
|
|
;; keeping it up to date, so there is nothing to read.
|
|
(t/is (nil? (usage-row team)))
|
|
;; Limit above one upload but below two: the live SUM (already
|
|
;; ~312 KB) rejects the second one.
|
|
(with-mocks [mock {:target 'app.config/get
|
|
:return (th/config-get-mock {:quotes-media-storage-bytes-per-team 400000})}]
|
|
(let [out (th/multipart-command! {::th/type :upload-file-media-object
|
|
::rpc/profile-id (:id prof)
|
|
:file-id (:id file)
|
|
:is-local true
|
|
:name "testfile2"
|
|
:content mfile})]
|
|
(t/is (some? (:error out)))
|
|
(t/is (= :max-quote-reached (:code (ex-data (:error out)))))))))))
|
|
|
|
(t/deftest storage-usage-scheduler-skips-live-teams
|
|
;; A second scheduler run while the workers are still queued
|
|
;; enqueues nothing: skip-if-active on the team id as label.
|
|
(let [prof-a (th/create-profile* 11)
|
|
prof-b (th/create-profile* 12)
|
|
team-a (:default-team-id prof-a)
|
|
team-b (:default-team-id prof-b)]
|
|
(with-redefs [cf/flags test-flags]
|
|
(append-row "upload-file-media-object" {:team-id team-b})
|
|
(append-row "upload-file-media-object" {:team-id team-a})
|
|
(th/run-task! :audit-log-consumer-events {})
|
|
(t/is (= 2 (th/run-task! :schedule-team-storage {})))
|
|
(t/is (= 0 (th/run-task! :schedule-team-storage {})))
|
|
(th/run-pending-jobs)
|
|
(t/is (nil? (:dirty-at (usage-row team-a))))
|
|
(t/is (nil? (:dirty-at (usage-row team-b)))))))
|
|
|
|
(t/deftest storage-usage-scheduler-enqueues-by-chunk-with-per-chunk-delay
|
|
;; With more dirty teams than fit one chunk, every chunk commits on
|
|
;; its own and carries a single delay (0, 5000, 10000...): the
|
|
;; stagger is per chunk, not per team.
|
|
(let [teams (mapv #(:default-team-id (th/create-profile* %)) (range 100 112))]
|
|
(with-redefs [cf/flags test-flags
|
|
team-storage/scheduler-chunk-size 5]
|
|
(doseq [team teams]
|
|
(append-row "upload-file-media-object" {:team-id team}))
|
|
(th/run-task! :audit-log-consumer-events {})
|
|
(t/is (= 12 (th/run-task! :schedule-team-storage {})))
|
|
(let [rows (th/db-exec! ["SELECT (round((extract(epoch from (scheduled_at - created_at)) * 1000)::numeric, -3))::bigint AS delay_ms FROM job WHERE name = 'recompute-team-storage'"])]
|
|
(t/is (= 12 (count rows)))
|
|
(t/is (= {0 5, 5000 5, 10000 2} (frequencies (map :delay-ms rows))))))))
|
|
|
|
(t/deftest storage-usage-scheduler-partial-progress-survives-mid-run-failure
|
|
;; Chunks commit independently: a failure mid-run keeps the chunks
|
|
;; already enqueued, and the next tick only submits the rest
|
|
;; (the live ones are skipped).
|
|
(let [teams (mapv #(:default-team-id (th/create-profile* %)) (range 120 132))]
|
|
(with-redefs [cf/flags test-flags
|
|
team-storage/scheduler-chunk-size 5]
|
|
(doseq [team teams]
|
|
(append-row "upload-file-media-object" {:team-id team}))
|
|
(th/run-task! :audit-log-consumer-events {})
|
|
(let [calls (atom 0)
|
|
orig jobs/submit]
|
|
(t/is (thrown? clojure.lang.ExceptionInfo
|
|
(with-redefs [jobs/submit (fn [cfg opts]
|
|
(when (> (swap! calls inc) 7)
|
|
(throw (ex-info "boom" {})))
|
|
(orig cfg opts))]
|
|
(th/run-task! :schedule-team-storage {}))))
|
|
;; The first chunk committed before the failure; the second
|
|
;; rolled back whole.
|
|
(t/is (= 5 (count (th/db-query :job {:name "recompute-team-storage"}))))
|
|
;; The next tick resumes: 5 live skipped, 7 fresh.
|
|
(t/is (= 7 (th/run-task! :schedule-team-storage {})))
|
|
(t/is (= 12 (count (th/db-query :job {:name "recompute-team-storage"}))))))))
|
|
|
|
(t/deftest audit-submit-does-not-swallow-failures
|
|
;; A lost event is a stale consumer: the failure has to surface, or
|
|
;; the counter drifts with no trace. (An event without a name fails
|
|
;; validation, which is enough to prove there is no catch.)
|
|
;; (the fixture stubs `submit`; this one is the real fn)
|
|
(t/is (thrown? Exception
|
|
(real-audit-submit th/*system* {:type "action"}))))
|
|
|
|
(t/deftest storage-usage-scheduler-sweeps-stale-rows
|
|
;; A mark lost on the way (failed audit insert, flag off) would keep
|
|
;; the counter low for ever: rows nobody refreshed for a week come
|
|
;; back to the queue, and fresh ones stay out.
|
|
(let [prof (th/create-profile* 87 {:is-active true})
|
|
stale (:id (th/create-team* 87 {:profile-id (:id prof)}))
|
|
fresh (:id (th/create-team* 88 {:profile-id (:id prof)}))
|
|
old (ct/in-past {:days 8})]
|
|
(th/db-insert! :team-storage-usage {:team-id stale :total-bytes 10 :updated-at old})
|
|
(th/db-insert! :team-storage-usage {:team-id fresh :total-bytes 10})
|
|
(with-redefs [cf/flags test-flags]
|
|
(t/is (= 1 (th/run-task! :schedule-team-storage {})))
|
|
(let [rows (th/db-exec! ["SELECT label FROM job WHERE name = 'recompute-team-storage'"])]
|
|
(t/is (= [(str stale)] (mapv :label rows)))))))
|
|
|
|
(t/deftest storage-usage-scheduler-enqueues-on-the-storage-queue
|
|
;; The recompute runs the slow per-team SUM, so it owns a queue:
|
|
;; it must never share the default runner with the delete cascades.
|
|
(let [prof (th/create-profile* 75 {:is-active true})
|
|
team (:default-team-id prof)]
|
|
(with-redefs [cf/flags test-flags]
|
|
(append-row "upload-file-media-object" {:team-id team})
|
|
(th/run-task! :audit-log-consumer-events {})
|
|
(t/is (= 1 (th/run-task! :schedule-team-storage {})))
|
|
(let [row (th/db-exec-one! ["SELECT queue FROM job WHERE name = 'recompute-team-storage'"])]
|
|
(t/is (= "storage" (:queue row)))))))
|
|
|
|
(t/deftest storage-usage-worker-skips-purged-team
|
|
;; A team purged after scheduling must not fail the worker: the
|
|
;; upsert only touches existing teams, so it ends normally with no
|
|
;; row instead of violating the foreign key into retries.
|
|
(let [prof (th/create-profile* 65 {:is-active true})
|
|
team (:id (th/create-team* 65 {:profile-id (:id prof)}))]
|
|
(with-redefs [cf/flags test-flags]
|
|
(append-row "upload-file-media-object" {:team-id team})
|
|
(th/run-task! :audit-log-consumer-events {})
|
|
(t/is (= 1 (th/run-task! :schedule-team-storage {})))
|
|
(with-redefs [sys/system th/*system*]
|
|
(srepl/delete-team! (str team)))
|
|
(th/run-task! :objects-gc {:skip-delay true})
|
|
(t/is (nil? (th/db-get :team {:id team} {::db/remove-deleted false})))
|
|
(t/is (== 0 (th/run-task! :recompute-team-storage {:team-id team})))
|
|
(t/is (nil? (usage-row team))))))
|
|
|
|
(t/deftest storage-usage-failed-worker-keeps-dirty-and-reschedules
|
|
;; A failing worker fails its own job and leaves the team dirty; the
|
|
;; next scheduler tick enqueues it again (no live job left to skip).
|
|
(let [prof (th/create-profile* 13)
|
|
team-id (:default-team-id prof)]
|
|
(with-redefs [cf/flags test-flags]
|
|
(append-row "upload-file-media-object" {:team-id team-id})
|
|
(th/run-task! :audit-log-consumer-events {})
|
|
(t/is (= 1 (th/run-task! :schedule-team-storage {})))
|
|
(let [live-fn quotes/get-live-media-storage-bytes]
|
|
(with-redefs [quotes/get-live-media-storage-bytes
|
|
(fn [conn id]
|
|
(if (= id team-id)
|
|
(throw (ex-info "boom" {}))
|
|
(live-fn conn id)))]
|
|
(try (th/run-task! :recompute-team-storage {:team-id team-id})
|
|
(catch Throwable _ nil))))
|
|
(t/is (some? (:dirty-at (usage-row team-id))))
|
|
;; The finished job is gone (reaped by the jobs GC in
|
|
;; production); the next tick schedules the team again and this
|
|
;; time it succeeds.
|
|
(th/db-force-delete :job {:name "recompute-team-storage"})
|
|
(t/is (= 1 (th/run-task! :schedule-team-storage {})))
|
|
(th/run-pending-jobs)
|
|
(let [row (usage-row team-id)
|
|
live (quotes/get-live-media-storage-bytes th/*system* team-id)]
|
|
(t/is (nil? (:dirty-at row)))
|
|
(t/is (== live (:total-bytes row)))))))
|
|
|
|
(t/deftest storage-usage-drain-skips-purged-teams
|
|
;; A team purged between queueing and drain must not wedge the chunk:
|
|
;; it is skipped and the surviving teams are still marked.
|
|
(let [prof (th/create-profile* 31)
|
|
team-doomed (:id (th/create-team* 31 {:profile-id (:id prof)}))
|
|
team-alive (:id (th/create-team* 32 {:profile-id (:id prof)}))]
|
|
(with-redefs [cf/flags test-flags]
|
|
(append-row "upload-file-media-object" {:team-id team-doomed})
|
|
(append-row "upload-file-media-object" {:team-id team-alive})
|
|
(th/command! {::th/type :delete-team
|
|
::rpc/profile-id (:id prof)
|
|
:id team-doomed})
|
|
(th/run-pending-jobs)
|
|
(th/run-task! :objects-gc {:skip-delay true})
|
|
(t/is (nil? (th/db-get :team {:id team-doomed} {::db/remove-deleted false})))
|
|
(th/run-task! :audit-log-consumer-events {})
|
|
;; The drain completed instead of aborting the chunk in a loop:
|
|
;; no pendings left and the surviving team is marked.
|
|
(t/is (= 0 (count (events-pendings))))
|
|
(t/is (some? (:dirty-at (usage-row team-alive)))))))
|
|
|
|
(t/deftest storage-usage-restore-team-moves-totals
|
|
;; Deleting a team zeroes its counter; restoring it brings the
|
|
;; media back and marks the team dirty again.
|
|
(let [prof (th/create-profile* 61 {:is-active true})
|
|
team (:id (th/create-team* 61 {:profile-id (:id prof)}))
|
|
proj (th/create-project* 61 {:profile-id (:id prof) :team-id team})
|
|
file (th/create-file* 61 {:profile-id (:id prof) :project-id (:id proj) :is-shared false})
|
|
mfile {:filename "sample.jpg"
|
|
:path (th/tempfile "backend_tests/test_files/sample.jpg")
|
|
:mtype "image/jpeg"
|
|
:size 312043}]
|
|
(with-redefs [cf/flags test-flags]
|
|
(let [out (th/multipart-command! {::th/type :upload-file-media-object
|
|
::rpc/profile-id (:id prof)
|
|
:file-id (:id file)
|
|
:is-local true
|
|
:name "testfile"
|
|
:content mfile})]
|
|
(t/is (nil? (:error out))))
|
|
(append-row "upload-file-media-object" {:team-id team :file-id (:id file)})
|
|
(th/run-task! :audit-log-consumer-events {})
|
|
(th/run-task! :schedule-team-storage {})
|
|
(th/run-pending-jobs)
|
|
(let [live (quotes/get-live-media-storage-bytes th/*system* team)]
|
|
(t/is (pos? live))
|
|
(t/is (== live (:total-bytes (usage-row team)))))
|
|
(with-redefs [sys/system th/*system*]
|
|
(srepl/delete-team! (str team)))
|
|
(th/run-task! :audit-log-consumer-events {})
|
|
(th/run-task! :schedule-team-storage {})
|
|
(th/run-pending-jobs)
|
|
(t/is (== 0 (:total-bytes (usage-row team))))
|
|
(with-redefs [sys/system th/*system*]
|
|
(srepl/restore-team! (str team)))
|
|
(t/is (= 1 (count (events-pendings))))
|
|
(th/run-task! :audit-log-consumer-events {})
|
|
(t/is (some? (:dirty-at (usage-row team))))
|
|
(th/run-task! :schedule-team-storage {})
|
|
(th/run-pending-jobs)
|
|
(let [live (quotes/get-live-media-storage-bytes th/*system* team)]
|
|
(t/is (pos? live))
|
|
(t/is (== live (:total-bytes (usage-row team))))))))
|
|
|
|
(t/deftest storage-usage-restore-file-marks-dirty
|
|
(let [prof (th/create-profile* 62 {:is-active true})
|
|
team (:default-team-id prof)
|
|
proj (th/create-project* 62 {:profile-id (:id prof) :team-id team})
|
|
file (th/create-file* 62 {:profile-id (:id prof) :project-id (:id proj) :is-shared false})]
|
|
(with-redefs [cf/flags test-flags]
|
|
(let [out (th/command! {::th/type :delete-file
|
|
::rpc/profile-id (:id prof)
|
|
:id (:id file)})]
|
|
(t/is (nil? (:error out))))
|
|
(th/run-pending-jobs)
|
|
(th/run-task! :audit-log-consumer-events {})
|
|
(th/run-task! :schedule-team-storage {})
|
|
(th/run-pending-jobs)
|
|
(with-redefs [sys/system th/*system*]
|
|
(srepl/restore-file! (str (:id file))))
|
|
(t/is (= 1 (count (events-pendings))))
|
|
(th/run-task! :audit-log-consumer-events {})
|
|
(t/is (some? (:dirty-at (usage-row team)))))))
|
|
|
|
(t/deftest storage-usage-restore-project-marks-dirty
|
|
(let [prof (th/create-profile* 63 {:is-active true})
|
|
team (:default-team-id prof)
|
|
proj (th/create-project* 63 {:profile-id (:id prof) :team-id team})]
|
|
(with-redefs [cf/flags test-flags]
|
|
(let [out (th/command! {::th/type :delete-project
|
|
::rpc/profile-id (:id prof)
|
|
:id (:id proj)})]
|
|
(t/is (nil? (:error out))))
|
|
(th/run-pending-jobs)
|
|
(th/run-task! :audit-log-consumer-events {})
|
|
(th/run-task! :schedule-team-storage {})
|
|
(th/run-pending-jobs)
|
|
(with-redefs [sys/system th/*system*]
|
|
(srepl/restore-project! (str (:id proj))))
|
|
(t/is (= 1 (count (events-pendings))))
|
|
(th/run-task! :audit-log-consumer-events {})
|
|
(t/is (some? (:dirty-at (usage-row team)))))))
|
|
|
|
(t/deftest storage-usage-restore-profile-marks-dirty
|
|
(let [prof (th/create-profile* 64 {:is-active true})
|
|
team (:default-team-id prof)]
|
|
(with-redefs [cf/flags test-flags]
|
|
(with-redefs [sys/system th/*system*]
|
|
(srepl/delete-profile! (str (:id prof))))
|
|
(th/run-task! :audit-log-consumer-events {})
|
|
(th/run-task! :schedule-team-storage {})
|
|
(th/run-pending-jobs)
|
|
(with-redefs [sys/system th/*system*]
|
|
(srepl/restore-profile! (str (:id prof))))
|
|
(t/is (= 1 (count (events-pendings))))
|
|
(th/run-task! :audit-log-consumer-events {})
|
|
(t/is (some? (:dirty-at (usage-row team)))))))
|
|
|
|
(t/deftest storage-usage-move-project-moves-totals
|
|
;; Moving a project between teams marks both teams dirty and moves
|
|
;; the totals: the origin stops counting the media, the destination
|
|
;; starts.
|
|
(let [prof (th/create-profile* 41 {:is-active true})
|
|
team-a (:default-team-id prof)
|
|
team-b (:id (th/create-team* 41 {:profile-id (:id prof)}))
|
|
proj-a (th/create-project* 41 {:profile-id (:id prof) :team-id team-a})
|
|
proj-b (th/create-project* 42 {:profile-id (:id prof) :team-id team-b})
|
|
file (th/create-file* 41 {:profile-id (:id prof) :project-id (:id proj-a) :is-shared false})
|
|
mfile {:filename "sample.jpg"
|
|
:path (th/tempfile "backend_tests/test_files/sample.jpg")
|
|
:mtype "image/jpeg"
|
|
:size 312043}]
|
|
(with-redefs [cf/flags test-flags]
|
|
(let [out (th/multipart-command! {::th/type :upload-file-media-object
|
|
::rpc/profile-id (:id prof)
|
|
:file-id (:id file)
|
|
:is-local true
|
|
:name "testfile"
|
|
:content mfile})]
|
|
(t/is (nil? (:error out))))
|
|
(th/run-task! :schedule-team-storage {})
|
|
(th/run-pending-jobs)
|
|
(let [live-a (quotes/get-live-media-storage-bytes th/*system* team-a)]
|
|
(t/is (pos? live-a))
|
|
(t/is (== live-a (:total-bytes (usage-row team-a))))))
|
|
;; The move emits both teams explicitly in its event props.
|
|
(let [wrapped (th/command-raw! {::th/type :move-project
|
|
::rpc/profile-id (:id prof)
|
|
:project-id (:id proj-a)
|
|
:team-id team-b})
|
|
props (::audit/props (meta wrapped))]
|
|
(t/is (= team-a (:source-team-id props)))
|
|
(t/is (= team-b (:dest-team-id props)))
|
|
(with-redefs [cf/flags test-flags]
|
|
;; The real team ids from the command, plus what wrap-audit and
|
|
;; the team enrichment would add (destination team and project).
|
|
(append-row "move-project" (assoc props
|
|
:team-id team-b
|
|
:project-id (:id proj-a)))
|
|
(th/run-task! :audit-log-consumer-events {})
|
|
(t/is (some? (:dirty-at (usage-row team-a))))
|
|
(t/is (some? (:dirty-at (usage-row team-b))))
|
|
(th/run-task! :schedule-team-storage {})
|
|
(th/run-pending-jobs)
|
|
(t/is (== 0 (:total-bytes (usage-row team-a))))
|
|
(let [live-b (quotes/get-live-media-storage-bytes th/*system* team-b)]
|
|
(t/is (pos? live-b))
|
|
(t/is (== live-b (:total-bytes (usage-row team-b)))))))))
|
|
|
|
(t/deftest storage-usage-move-files-moves-totals
|
|
;; Moving a file to a project of another team marks both teams dirty
|
|
;; and moves the totals.
|
|
(let [prof (th/create-profile* 42 {:is-active true})
|
|
team-a (:default-team-id prof)
|
|
team-b (:id (th/create-team* 42 {:profile-id (:id prof)}))
|
|
proj-a (th/create-project* 43 {:profile-id (:id prof) :team-id team-a})
|
|
proj-b (th/create-project* 44 {:profile-id (:id prof) :team-id team-b})
|
|
file (th/create-file* 42 {:profile-id (:id prof) :project-id (:id proj-a) :is-shared false})
|
|
mfile {:filename "sample.jpg"
|
|
:path (th/tempfile "backend_tests/test_files/sample.jpg")
|
|
:mtype "image/jpeg"
|
|
:size 312043}]
|
|
(with-redefs [cf/flags test-flags]
|
|
(let [out (th/multipart-command! {::th/type :upload-file-media-object
|
|
::rpc/profile-id (:id prof)
|
|
:file-id (:id file)
|
|
:is-local true
|
|
:name "testfile"
|
|
:content mfile})]
|
|
(t/is (nil? (:error out))))
|
|
(th/run-task! :schedule-team-storage {})
|
|
(th/run-pending-jobs)
|
|
(t/is (pos? (:total-bytes (usage-row team-a)))))
|
|
;; The move emits both teams explicitly in its event props.
|
|
(let [wrapped (th/command-raw! {::th/type :move-files
|
|
::rpc/profile-id (:id prof)
|
|
:project-id (:id proj-b)
|
|
:ids #{(:id file)}})
|
|
props (::audit/props (meta wrapped))]
|
|
(t/is (= team-a (:source-team-id props)))
|
|
(t/is (= team-b (:dest-team-id props)))
|
|
(with-redefs [cf/flags test-flags]
|
|
;; The real origin ids from the command, plus what wrap-audit
|
|
;; and the team enrichment would add.
|
|
(append-row "move-files" (assoc props
|
|
:team-id team-b
|
|
:project-id (:id proj-b)))
|
|
(th/run-task! :audit-log-consumer-events {})
|
|
(t/is (some? (:dirty-at (usage-row team-a))))
|
|
(t/is (some? (:dirty-at (usage-row team-b))))
|
|
(th/run-task! :schedule-team-storage {})
|
|
(th/run-pending-jobs)
|
|
(t/is (== 0 (:total-bytes (usage-row team-a))))
|
|
(let [live-b (quotes/get-live-media-storage-bytes th/*system* team-b)]
|
|
(t/is (pos? live-b))
|
|
(t/is (== live-b (:total-bytes (usage-row team-b)))))))))
|
|
|
|
(t/deftest storage-usage-bulk-ingest-queues-nothing
|
|
;; Frontend bulk ingest can never produce a storage-usage event:
|
|
;; pin that it queues nothing for consumer 3.
|
|
(let [prof (th/create-profile* 1 {:is-active true})]
|
|
(with-redefs [cf/flags #{:audit-log :storage-usage}]
|
|
(@#'cmd-audit/handle-events
|
|
{::db/pool th/*pool* ::mtx/metrics (th/metrics-stub)}
|
|
(with-meta {::rpc/profile-id (:id prof)
|
|
::rpc/request-at (ct/now)
|
|
:events [{:name "navigate" :type "action"
|
|
:props {:route "dashboard"}
|
|
:timestamp (ct/now)}]}
|
|
{:app.http/request (th/make-dummy-request)}))
|
|
(t/is (= 0 (count (events-pendings)))))))
|
|
|
|
(t/deftest storage-usage-missing-row-fails-open
|
|
(let [prof (th/create-profile* 1)
|
|
team-id (:default-team-id prof)]
|
|
(with-redefs [cf/flags test-flags]
|
|
(quotes/check! th/*system* {::quotes/id ::quotes/media-storage-bytes-per-team
|
|
::quotes/profile-id (:id prof)
|
|
::quotes/team-id team-id
|
|
::quotes/incr 0})
|
|
(let [row (usage-row team-id)]
|
|
(t/is (some? row))
|
|
(t/is (some? (:dirty-at row)))
|
|
(t/is (= 0 (:total-bytes row))))
|
|
;; The row born on quota check is picked up by the next tick.
|
|
(t/is (= 1 (th/run-task! :schedule-team-storage {})))
|
|
(th/run-pending-jobs)
|
|
(let [row (usage-row team-id)
|
|
live (quotes/get-live-media-storage-bytes th/*system* team-id)]
|
|
(t/is (nil? (:dirty-at row)))
|
|
(t/is (== live (:total-bytes row)))))))
|
|
|
|
(t/deftest storage-usage-file-gc-stays-silent-without-trims
|
|
;; The event is gated on actually trimming media: without that, a
|
|
;; cron walk over every file would flood the audit buffer with one
|
|
;; row plus one pending per file it touches.
|
|
(let [prof (th/create-profile* 2)
|
|
team-id (:default-team-id prof)
|
|
proj (th/create-project* 2 {:profile-id (:id prof) :team-id team-id})
|
|
file (th/create-file* 2 {:profile-id (:id prof) :project-id (:id proj) :is-shared false})
|
|
submitted (atom nil)]
|
|
(with-redefs [cf/flags test-flags
|
|
audit/submit (fn [_ event] (reset! submitted event) nil)]
|
|
(let [revn (:revn (th/db-exec-one! ["SELECT revn FROM file WHERE id = ?" (:id file)]))]
|
|
(th/run-task! :file-gc {:file-id (:id file) :revn revn}))
|
|
(t/is (nil? @submitted)))))
|
|
|
|
(t/deftest storage-usage-file-gc-emits-event
|
|
(let [prof (th/create-profile* 1)
|
|
team-id (:default-team-id prof)
|
|
proj (th/create-project* 1 {:profile-id (:id prof) :team-id team-id})
|
|
file (th/create-file* 1 {:profile-id (:id prof) :project-id (:id proj) :is-shared false})
|
|
mfile {:filename "sample.jpg"
|
|
:path (th/tempfile "backend_tests/test_files/sample.jpg")
|
|
:mtype "image/jpeg"
|
|
:size 312043}
|
|
submitted (atom nil)]
|
|
(with-redefs [cf/flags test-flags
|
|
audit/submit (fn [_ event] (reset! submitted event) nil)]
|
|
(let [out (th/multipart-command! {::th/type :upload-file-media-object
|
|
::rpc/profile-id (:id prof)
|
|
:file-id (:id file)
|
|
:is-local true
|
|
:name "testfile"
|
|
:content mfile})]
|
|
(t/is (nil? (:error out))))
|
|
(let [revn (:revn (th/db-exec-one! ["SELECT revn FROM file WHERE id = ?" (:id file)]))]
|
|
(th/run-task! :file-gc {:file-id (:id file) :revn revn}))
|
|
(let [event @submitted]
|
|
(t/is (= "file-gc" (:name event)))
|
|
(t/is (= "action" (:type event)))
|
|
(t/is (= "cron-task" (-> event :context :triggered-by)))
|
|
(t/is (= (:id file) (-> event :props :file-id)))
|
|
(t/is (= team-id (-> event :props :team-id)))))
|
|
(with-redefs [cf/flags test-flags]
|
|
(append-row "file-gc" {:file-id (:id file) :team-id team-id})
|
|
(th/run-task! :audit-log-consumer-events {})
|
|
(t/is (some? (:dirty-at (usage-row team-id))))
|
|
(t/is (= 1 (th/run-task! :schedule-team-storage {})))
|
|
(th/run-pending-jobs)
|
|
(t/is (nil? (:dirty-at (usage-row team-id))))
|
|
(append-row "delete-object-with-cascade" {:cascade {:team-ids [team-id]}})
|
|
(th/run-task! :audit-log-consumer-events {})
|
|
(t/is (some? (:dirty-at (usage-row team-id)))))))
|