web: add node opt-out marker and data retention policies (#793)

* web: add node opt-out marker and data retention policies

* web: address review comments

* web: address review comments
This commit is contained in:
l5y
2026-05-20 21:02:59 +02:00
committed by GitHub
parent 8a89185fbe
commit d2fe4f8223
18 changed files with 1672 additions and 29 deletions
+9
View File
@@ -61,6 +61,7 @@ require_relative "application/filesystem"
require_relative "application/api_cache"
require_relative "application/pages"
require_relative "application/instances"
require_relative "application/retention"
require_relative "application/routes/api"
require_relative "application/routes/ingest"
require_relative "application/routes/root"
@@ -78,6 +79,7 @@ module PotatoMesh
extend App::DataProcessing
extend App::Filesystem
extend App::Pages
extend App::Retention
helpers App::Helpers
include App::Database
@@ -90,6 +92,7 @@ module PotatoMesh
include App::DataProcessing
include App::Filesystem
include App::Pages
include App::Retention
register App::Routes::Api
register App::Routes::Ingest
@@ -148,6 +151,9 @@ module PotatoMesh
set :federation_worker_pool, nil
set :federation_shutdown_requested, false
set :federation_shutdown_hook_installed, false
set :retention_thread, nil
set :retention_shutdown_requested, false
set :retention_shutdown_hook_installed, false
set :port, resolve_port
set :bind, DEFAULT_BIND_ADDRESS
@@ -171,6 +177,8 @@ module PotatoMesh
ensure_self_instance_record!
update_all_prometheus_metrics_from_nodes
start_retention_worker_if_active!
if federation_enabled?
ensure_federation_worker_pool!
else
@@ -216,6 +224,7 @@ SELF_INSTANCE_ID = PotatoMesh::Application::SELF_INSTANCE_ID unless defined?(SEL
PotatoMesh::App::Queries,
PotatoMesh::App::DataProcessing,
PotatoMesh::App::Pages,
PotatoMesh::App::Retention,
].each do |mod|
Object.include(mod) unless Object < mod
end
@@ -19,6 +19,11 @@ module PotatoMesh
module Federation
# Count the number of nodes active since the supplied timestamp.
#
# Opted-out nodes (those whose +short_name+ or +long_name+ contains
# the {PotatoMesh::Config::NODE_OPT_OUT_MARKER}) are excluded so the
# federation node total never reflects nodes that have asked to stay
# hidden from API consumers.
#
# @param cutoff [Integer] unix timestamp in seconds.
# @param db [SQLite3::Database, nil] optional open handle to reuse.
# @return [Integer, nil] node count or nil when unavailable.
@@ -26,9 +31,10 @@ module PotatoMesh
return nil unless cutoff
handle = db || open_database(readonly: true)
sql = "SELECT COUNT(*) FROM nodes WHERE last_heard >= ? AND #{opt_out_self_filter}"
count =
with_busy_retry do
handle.get_first_value("SELECT COUNT(*) FROM nodes WHERE last_heard >= ?", cutoff.to_i)
handle.get_first_value(sql, [cutoff.to_i, *opt_out_marker_params])
end
Integer(count)
rescue SQLite3::Exception, ArgumentError => e
@@ -44,7 +50,8 @@ module PotatoMesh
end
# Count the number of nodes for a specific protocol active since the
# supplied timestamp.
# supplied timestamp. Opted-out nodes are excluded for the same
# reason as in {#active_node_count_since}.
#
# @param cutoff [Integer] unix timestamp in seconds.
# @param protocol [String] protocol name (e.g. "meshcore", "meshtastic").
@@ -54,13 +61,10 @@ module PotatoMesh
return nil unless cutoff && protocol
handle = db || open_database(readonly: true)
sql = "SELECT COUNT(*) FROM nodes WHERE last_heard >= ? AND protocol = ? AND #{opt_out_self_filter}"
count =
with_busy_retry do
handle.get_first_value(
"SELECT COUNT(*) FROM nodes WHERE last_heard >= ? AND protocol = ?",
cutoff.to_i,
protocol,
)
handle.get_first_value(sql, [cutoff.to_i, protocol, *opt_out_marker_params])
end
Integer(count)
rescue SQLite3::Exception, ArgumentError => e
@@ -28,6 +28,17 @@ module PotatoMesh
PotatoMesh::Logging.log(logger, :debug, message, context: context, **metadata)
end
# Emit a structured info log entry tagged with the calling context.
#
# @param message [String] text to emit.
# @param context [String] logical source of the message.
# @param metadata [Hash] additional structured key/value data.
# @return [void]
def info_log(message, context: "app", **metadata)
logger = PotatoMesh::Logging.logger_for(self)
PotatoMesh::Logging.log(logger, :info, message, context: context, **metadata)
end
# Emit a structured warning log entry tagged with the calling context.
#
# @param message [String] text to emit.
+6 -1
View File
@@ -242,12 +242,17 @@ module PotatoMesh
# Retrieve the latest node update timestamp from the database.
#
# Opted-out nodes are excluded so the +/version+ cache hint and the
# federation self-record do not leak the freshness of nodes that have
# asked to stay hidden.
#
# @return [Integer, nil] Unix timestamp or nil when unavailable.
def latest_node_update_timestamp
return nil unless File.exist?(PotatoMesh::Config.db_path)
db = open_database(readonly: true)
value = db.get_first_value("SELECT MAX(last_heard) FROM nodes")
sql = "SELECT MAX(last_heard) FROM nodes WHERE #{opt_out_self_filter}"
value = db.get_first_value(sql, opt_out_marker_params)
value&.to_i
rescue SQLite3::Exception
nil
@@ -53,6 +53,11 @@ module PotatoMesh
params.concat(clause.last)
end
# Hide chat lines that originate from or are addressed to opted-out
# nodes. Both endpoints are filtered so reactions/replies aimed at a
# silenced participant do not leak the conversation half.
append_opt_out_filter(where_clauses, params, opt_out_node_id_filter("m.from_id"))
append_opt_out_filter(where_clauses, params, opt_out_node_id_filter("m.to_id"))
append_protocol_filter(where_clauses, params, protocol, table_alias: "m")
sql = <<~SQL
@@ -224,6 +224,124 @@ module PotatoMesh
threshold = 0 if threshold.nil? || threshold.negative?
[threshold, floor].max
end
# SQL fragment used by every read query to filter out opted-out nodes.
#
# Operators signal opt-out by placing
# {PotatoMesh::Config::NODE_OPT_OUT_MARKER} (🛑) anywhere in their
# +short_name+ or +long_name+. The data layer still ingests records
# for these nodes — the marker only suppresses them from API responses.
#
# Wrapping each display column in +COALESCE(...,'')+ matters because
# SQL +LIKE+ against +NULL+ yields +NULL+, which is falsy in +WHERE+
# but propagates through +NOT+ as +NULL+ — without the coalesce, any
# node missing one display column would be incorrectly filtered out.
OPT_OUT_NAME_PREDICATE =
"(COALESCE(long_name, '') LIKE '%' || ? || '%' " \
"OR COALESCE(short_name, '') LIKE '%' || ? || '%')".freeze
# Returns the bind parameters required by every opt-out SQL fragment.
#
# The fragments embed two LIKE expressions (one per display column), so
# each invocation needs the marker twice. Centralising the binding
# avoids drift between fragments that match against +nodes+ directly
# versus those that join via a subquery.
#
# @return [Array<String>] bind parameters for the opt-out predicate.
def opt_out_marker_params
marker = PotatoMesh::Config.node_opt_out_marker
[marker, marker]
end
# SQL fragment that excludes rows whose own +long_name+/+short_name+
# carry the opt-out marker. Intended for queries that read directly
# from the +nodes+ table.
#
# @return [String] SQL predicate suitable for AND-composition.
def opt_out_self_filter
"NOT #{OPT_OUT_NAME_PREDICATE}"
end
# Regex matching the only column-name shapes the +opt_out_node_*_filter+
# helpers accept: bare identifiers (+node_id+), and dotted qualifiers
# (+m.from_id+). Anything else is rejected because the value is
# interpolated directly into SQL, where stray punctuation would either
# corrupt the query or open a SQL-injection surface for any future
# caller that forgets to pass a literal.
SAFE_COLUMN_IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?\z/.freeze
# Validate that +column+ is a plain identifier or dotted alias before
# interpolation. Raises +ArgumentError+ on anything more exotic.
#
# @param column [String] candidate column name.
# @return [String] +column+ unchanged when safe to interpolate.
def assert_safe_column_identifier!(column)
unless column.is_a?(String) && column.match?(SAFE_COLUMN_IDENTIFIER)
raise ArgumentError, "unsafe column identifier: #{column.inspect}"
end
column
end
# SQL fragment that excludes rows whose textual node reference column
# points at an opted-out node. Use for tables that join logically via
# a +node_id+/+from_id+/+to_id+ column.
#
# NULL references on the outer column are preserved so anonymous chat
# messages and other records without an attributable sender remain
# visible. The inner subquery also filters +node_id IS NOT NULL+: in
# SQLite, +x NOT IN (subquery)+ returns UNKNOWN when the subquery
# produces a NULL, which would silently exclude every row. Guarding
# the subquery keeps that failure mode out of reach if a future opt-out
# row ever lands with a NULL +node_id+.
#
# @param column [String] qualified SQL column name (e.g. ``"m.from_id"``).
# Must match {SAFE_COLUMN_IDENTIFIER}; arbitrary user input is not
# accepted.
# @return [String] SQL predicate suitable for AND-composition.
def opt_out_node_id_filter(column)
assert_safe_column_identifier!(column)
"(#{column} IS NULL OR #{column} NOT IN (" \
"SELECT node_id FROM nodes WHERE node_id IS NOT NULL AND #{OPT_OUT_NAME_PREDICATE}))"
end
# SQL fragment that excludes rows whose numeric node reference column
# points at an opted-out node. Use for tables that key on the legacy
# numeric node identifier (+num+, +src+, +dest+, +trace_hops.node_id+).
#
# @param column [String] qualified SQL column name. Must match
# {SAFE_COLUMN_IDENTIFIER}.
# @return [String] SQL predicate suitable for AND-composition.
def opt_out_node_num_filter(column)
assert_safe_column_identifier!(column)
"(#{column} IS NULL OR #{column} NOT IN (" \
"SELECT num FROM nodes WHERE num IS NOT NULL AND #{OPT_OUT_NAME_PREDICATE}))"
end
# Append an opt-out filter to an in-flight WHERE clause builder.
#
# @param where_clauses [Array<String>] accumulating WHERE conditions.
# @param params [Array] accumulating bind parameters.
# @param fragment [String] SQL fragment produced by one of the
# +opt_out_*_filter+ helpers.
# @return [void]
def append_opt_out_filter(where_clauses, params, fragment)
where_clauses << fragment
params.concat(opt_out_marker_params)
end
# Clamp a caller-supplied window duration to the 28-day API visibility
# cap. Used by aggregate endpoints whose +windowSeconds+ parameter
# could otherwise reach further back than the per-id read floor.
#
# @param window_seconds [Integer, nil] requested window duration.
# @return [Integer, nil] +window_seconds+ clamped to at most 28 days,
# or +nil+ when the input is non-positive/nil.
def clamp_window_seconds(window_seconds)
return nil if window_seconds.nil?
return nil if window_seconds <= 0
cap = PotatoMesh::Config.four_weeks_seconds
window_seconds > cap ? cap : window_seconds
end
end
end
end
@@ -44,6 +44,7 @@ module PotatoMesh
params.concat(clause.last)
end
append_opt_out_filter(where_clauses, params, opt_out_node_id_filter("node_id"))
append_protocol_filter(where_clauses, params, protocol)
sql = <<~SQL
@@ -106,6 +107,11 @@ module PotatoMesh
params.concat(clause.last)
end
# Either endpoint of the neighbour relationship may carry the
# opt-out marker — filter both so a silenced node never appears as
# a source or destination of an RF link.
append_opt_out_filter(where_clauses, params, opt_out_node_id_filter("node_id"))
append_opt_out_filter(where_clauses, params, opt_out_node_id_filter("neighbor_id"))
append_protocol_filter(where_clauses, params, protocol)
sql = <<~SQL
@@ -163,6 +169,12 @@ module PotatoMesh
3.times { params.concat(numeric_values) }
end
# Drop traces whose endpoints carry the opt-out marker. Hops are
# filtered separately at hydration time so a trace that only relays
# through a silenced node still surfaces with the offending hop
# removed.
append_opt_out_filter(where_clauses, params, opt_out_node_num_filter("src"))
append_opt_out_filter(where_clauses, params, opt_out_node_num_filter("dest"))
append_protocol_filter(where_clauses, params, protocol)
sql = <<~SQL
@@ -181,10 +193,15 @@ module PotatoMesh
hops_by_trace = Hash.new { |hash, key| hash[key] = [] }
unless trace_ids.empty?
placeholders = Array.new(trace_ids.length, "?").join(", ")
# Hide opted-out intermediate hops too — otherwise a single trace
# could expose a silenced node's numeric ID via the relay chain.
hop_filter = opt_out_node_num_filter("th.node_id")
hop_rows =
db.execute(
"SELECT trace_id, hop_index, node_id FROM trace_hops WHERE trace_id IN (#{placeholders}) ORDER BY trace_id, hop_index",
trace_ids,
"SELECT th.trace_id, th.hop_index, th.node_id FROM trace_hops th " \
"WHERE th.trace_id IN (#{placeholders}) AND #{hop_filter} " \
"ORDER BY th.trace_id, th.hop_index",
trace_ids + opt_out_marker_params,
)
hop_rows.each do |hop|
trace_id = coerce_integer(hop["trace_id"])
@@ -163,6 +163,7 @@ module PotatoMesh
where_clauses << "(role IS NULL OR role <> 'CLIENT_HIDDEN')"
end
append_opt_out_filter(where_clauses, params, opt_out_self_filter)
append_protocol_filter(where_clauses, params, protocol)
sql = <<~SQL
@@ -235,6 +236,7 @@ module PotatoMesh
since_threshold = normalize_since_threshold(since, floor: cutoff)
where_clauses = ["last_seen_time >= ?"]
params = [since_threshold]
append_opt_out_filter(where_clauses, params, opt_out_node_id_filter("node_id"))
append_protocol_filter(where_clauses, params, protocol)
sql = <<~SQL
SELECT node_id, start_time, last_seen_time, version, lora_freq, modem_preset, protocol
@@ -282,27 +284,39 @@ module PotatoMesh
hour_cutoff = reference_now - 3600
day_cutoff = reference_now - 86_400
week_cutoff = reference_now - PotatoMesh::Config.week_seconds
month_cutoff = reference_now - (30 * 24 * 60 * 60)
pf = private_mode? ? " AND (role IS NULL OR role <> 'CLIENT_HIDDEN')" : ""
proto = " AND protocol = ?"
# The "month" bucket reuses the four-week cap so no stats endpoint can
# surface activity from beyond the 28-day API visibility floor.
month_cutoff = reference_now - PotatoMesh::Config.four_weeks_seconds
private_clause = private_mode? ? " AND (role IS NULL OR role <> 'CLIENT_HIDDEN')" : ""
# Materialise the visible-nodes projection in a CTE so the opt-out
# LIKE predicate is evaluated once and the marker is bound twice in
# total instead of twice per subquery (44 → 22 binds). Every COUNT
# below then operates on the pre-filtered set, which also matches the
# rest of the read API's "no opt-out node ever leaves the database
# layer" invariant.
sql = <<~SQL
WITH visible_nodes AS (
SELECT last_heard, protocol
FROM nodes
WHERE #{opt_out_self_filter}#{private_clause}
)
SELECT
(SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{pf}) AS hour_count,
(SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{pf}) AS day_count,
(SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{pf}) AS week_count,
(SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{pf}) AS month_count,
(SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{pf}#{proto}) AS mc_hour,
(SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{pf}#{proto}) AS mc_day,
(SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{pf}#{proto}) AS mc_week,
(SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{pf}#{proto}) AS mc_month,
(SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{pf}#{proto}) AS mt_hour,
(SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{pf}#{proto}) AS mt_day,
(SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{pf}#{proto}) AS mt_week,
(SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{pf}#{proto}) AS mt_month
(SELECT COUNT(*) FROM visible_nodes WHERE last_heard >= ?) AS hour_count,
(SELECT COUNT(*) FROM visible_nodes WHERE last_heard >= ?) AS day_count,
(SELECT COUNT(*) FROM visible_nodes WHERE last_heard >= ?) AS week_count,
(SELECT COUNT(*) FROM visible_nodes WHERE last_heard >= ?) AS month_count,
(SELECT COUNT(*) FROM visible_nodes WHERE last_heard >= ? AND protocol = ?) AS mc_hour,
(SELECT COUNT(*) FROM visible_nodes WHERE last_heard >= ? AND protocol = ?) AS mc_day,
(SELECT COUNT(*) FROM visible_nodes WHERE last_heard >= ? AND protocol = ?) AS mc_week,
(SELECT COUNT(*) FROM visible_nodes WHERE last_heard >= ? AND protocol = ?) AS mc_month,
(SELECT COUNT(*) FROM visible_nodes WHERE last_heard >= ? AND protocol = ?) AS mt_hour,
(SELECT COUNT(*) FROM visible_nodes WHERE last_heard >= ? AND protocol = ?) AS mt_day,
(SELECT COUNT(*) FROM visible_nodes WHERE last_heard >= ? AND protocol = ?) AS mt_week,
(SELECT COUNT(*) FROM visible_nodes WHERE last_heard >= ? AND protocol = ?) AS mt_month
SQL
cutoffs = [hour_cutoff, day_cutoff, week_cutoff, month_cutoff]
# Total counts bind only cutoffs; per-protocol counts bind cutoff + protocol string.
params = cutoffs +
params = opt_out_marker_params + cutoffs +
cutoffs.flat_map { |c| [c, "meshcore"] } +
cutoffs.flat_map { |c| [c, "meshtastic"] }
row = with_busy_retry do
@@ -44,6 +44,7 @@ module PotatoMesh
params.concat(clause.last)
end
append_opt_out_filter(where_clauses, params, opt_out_node_id_filter("node_id"))
append_protocol_filter(where_clauses, params, protocol)
sql = <<~SQL
@@ -117,6 +118,12 @@ module PotatoMesh
def query_telemetry_buckets(window_seconds:, bucket_seconds:, since: 0)
window = coerce_integer(window_seconds) || DEFAULT_TELEMETRY_WINDOW_SECONDS
window = DEFAULT_TELEMETRY_WINDOW_SECONDS if window <= 0
# Hard-cap the aggregation window at the 28-day visibility floor so
# callers cannot bypass the data-retention policy via an oversized
# +windowSeconds+ parameter. The route layer also calls
# +clamp_window_seconds+ up-front; reusing the same helper here keeps
# the cap defined in exactly one place.
window = clamp_window_seconds(window) || DEFAULT_TELEMETRY_WINDOW_SECONDS
bucket = coerce_integer(bucket_seconds) || DEFAULT_TELEMETRY_BUCKET_SECONDS
bucket = DEFAULT_TELEMETRY_BUCKET_SECONDS if bucket <= 0
@@ -146,11 +153,12 @@ module PotatoMesh
FROM telemetry
WHERE COALESCE(rx_time, telemetry_time) IS NOT NULL
AND COALESCE(rx_time, telemetry_time, 0) >= ?
AND #{opt_out_node_id_filter("node_id")}
GROUP BY bucket_start
ORDER BY bucket_start ASC
LIMIT ?
SQL
params = [bucket, bucket, since_threshold, MAX_QUERY_LIMIT]
params = [bucket, bucket, since_threshold, *opt_out_marker_params, MAX_QUERY_LIMIT]
rows = db.execute(sql, params)
rows.map do |row|
bucket_start = coerce_integer(row["bucket_start"])
@@ -0,0 +1,310 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# frozen_string_literal: true
module PotatoMesh
module App
# Background data retention enforcement.
#
# The retention module deletes rows whose most recent activity timestamp
# is older than {PotatoMesh::Config.year_seconds} (365 days). Two
# mechanisms compose to make this safe:
#
# * Each table is purged on its own timestamp column ({.RETENTION_TARGETS})
# so a stale row in one table never holds another table hostage.
# * Foreign keys defined in the schema (e.g. +neighbors+ → +nodes+,
# +trace_hops+ → +traces+) propagate cascading deletes for dependent
# rows, keeping the database consistent without bespoke DELETE
# statements per relationship.
#
# The purge runs in a dedicated daemon thread spawned during the
# Sinatra +configure+ block. An +at_exit+ hook tears the thread down
# cleanly on process exit, mirroring the federation announcer pattern.
module Retention
# Tables purged on each cycle, paired with the column whose value is
# compared against the retention cutoff. Each entry's column points at
# the freshest activity timestamp for the table — when that column
# drops below +(now - year_seconds)+ the row is unrecoverable through
# the API anyway, so it is safe to remove from disk.
#
# Ordering matters: child tables that participate in +ON DELETE CASCADE+
# relationships (notably +neighbors+, which references +nodes+) are
# purged *before* their parents. Otherwise the parent purge would
# cascade-delete the same rows the explicit child DELETE was about to
# touch, leaving +db.changes+ to under-report the work done.
RETENTION_TARGETS = [
["neighbors", "rx_time"],
["messages", "rx_time"],
["positions", "rx_time"],
["telemetry", "rx_time"],
["traces", "rx_time"],
["ingestors", "last_seen_time"],
["nodes", "last_heard"],
].freeze
# Run a single retention sweep, deleting rows older than +cutoff+ from
# every {RETENTION_TARGETS} entry. Each table is wrapped in its own
# +with_busy_retry+ to stay resilient to short-lived SQLite locks held
# by the ingestor.
#
# @param now [Integer] reference unix timestamp; defaults to the
# current wall-clock seconds. Exposed for tests so they can fast
# forward without manipulating system time.
# @return [Hash{String => Integer}] count of rows removed per table.
def purge_old_data!(now: Time.now.to_i)
cutoff = now - PotatoMesh::Config.year_seconds
removed = Hash.new(0)
db = open_database
begin
# Foreign-key cascades only fire when PRAGMA foreign_keys = ON,
# which open_database already enforces. Relying on the schema
# cascades keeps the DELETE list small and avoids the need to
# manually clean +trace_hops+ before its parent.
#
# The whole sweep runs inside a single transaction so a crash or
# shutdown mid-pass either commits every table's deletions or
# leaves the DB untouched — never half-purged.
with_busy_retry do
db.transaction do
RETENTION_TARGETS.each do |table, column|
sql = "DELETE FROM #{table} WHERE #{column} IS NOT NULL AND #{column} < ?"
db.execute(sql, [cutoff])
removed[table] = db.changes
end
end
end
ensure
db&.close
end
# Promoted to info so retention activity is visible at the default
# log level — on a busy node the per-table removal counts are useful
# operational signal, not debug noise.
info_log(
"Purged data outside retention window",
context: "retention.purge",
cutoff: cutoff,
removed: removed,
)
removed
rescue StandardError => e
warn_log(
"Retention purge failed",
context: "retention.purge",
error_class: e.class.name,
error_message: e.message,
)
{}
end
# Whether the periodic retention worker should run for the current
# process. Mirrors {Helpers#federation_announcements_active?} so the
# test suite does not spawn a long-lived sleeper that briefly holds
# database write locks while specs are executing.
#
# {Helpers#test_environment?} is included into +Object+ at boot, so
# every host class transitively has the predicate available.
#
# @return [Boolean] +false+ in the +RACK_ENV=test+ environment,
# +true+ otherwise.
def retention_worker_active?
!test_environment?
end
# Entry point invoked from the Sinatra +configure+ block. Clearing
# the shutdown flag before checking +start_retention_thread!+'s
# alive-guard is safe: if an old worker is still running it will keep
# going (the alive-check short-circuits the spawn), and if it is not,
# the flag must be clear for the freshly spawned thread to make any
# progress. No in-flight shutdown can be in progress here because
# +configure+ runs single-threaded at boot.
#
# In the test environment ({#retention_worker_active?} is +false+) the
# worker is intentionally skipped so specs do not race against the
# purge loop's DELETEs.
#
# @return [Thread, nil] the worker thread when spawned, +nil+ when the
# environment opted out.
def start_retention_worker_if_active!
clear_retention_shutdown_request!
if retention_worker_active?
start_retention_thread!
else
debug_log(
"Retention worker disabled",
context: "retention",
reason: "test environment",
)
nil
end
end
# Spawn the long-running retention worker. Mirrors the federation
# announcer thread layout — short shutdown-aware sleeps so the daemon
# exits promptly when the process is shutting down.
#
# @return [Thread, nil] the worker thread, or +nil+ when already running.
def start_retention_thread!
ensure_retention_shutdown_hook!
existing = settings.respond_to?(:retention_thread) ? settings.retention_thread : nil
return existing if existing&.alive?
thread = Thread.new do
retention_thread_loop
end
thread.name = "potato-mesh-retention" if thread.respond_to?(:name=)
thread.daemon = true if thread.respond_to?(:daemon=)
set(:retention_thread, thread) if respond_to?(:set)
thread
end
# Driver loop for the retention worker. Extracted so unit tests can
# exercise the body in isolation without spawning a real Thread.
#
# @return [void]
def retention_thread_loop
# Wait briefly before the first sweep so schema migrations and
# federation handshakes complete before a long DELETE acquires write
# locks.
delay = PotatoMesh::Config.initial_retention_delay_seconds
return unless retention_sleep_with_shutdown(delay)
loop do
# purge_old_data! rescues StandardError internally and logs to the
# "retention.purge" context, so the loop body is guaranteed not to
# raise. No outer rescue is needed.
purge_old_data!
break unless retention_sleep_with_shutdown(
PotatoMesh::Config.retention_purge_interval_seconds,
)
end
end
# Sleep in small slices so the retention worker reacts quickly to a
# shutdown request. Mirrors {Federation#federation_sleep_with_shutdown}
# so behaviour is consistent across long-lived workers.
#
# @param seconds [Numeric] total sleep duration.
# @return [Boolean] +true+ when the full delay elapsed, +false+ when a
# shutdown was requested mid-sleep.
def retention_sleep_with_shutdown(seconds)
remaining = seconds.to_f
slice_size = 0.2
while remaining.positive?
return false if retention_shutdown_requested?
slice = [remaining, slice_size].min
Kernel.sleep(slice)
remaining -= slice
end
!retention_shutdown_requested?
end
# Whether a retention shutdown has been requested.
#
# @return [Boolean]
def retention_shutdown_requested?
return false unless respond_to?(:settings)
return false unless settings.respond_to?(:retention_shutdown_requested)
settings.retention_shutdown_requested == true
end
# Request the retention thread to exit at the next slice boundary.
#
# @return [void]
def request_retention_shutdown!
set(:retention_shutdown_requested, true) if respond_to?(:set)
end
# Clear any retention shutdown request. Called at boot so a previous
# shutdown does not stop a freshly started worker.
#
# @return [void]
def clear_retention_shutdown_request!
set(:retention_shutdown_requested, false) if respond_to?(:set)
end
# Tear down the retention thread, joining it within the configured
# shutdown timeout. Invoked from the +at_exit+ hook installed by
# {#ensure_retention_shutdown_hook!}.
#
# @param timeout [Numeric] seconds to wait for clean exit.
# @return [void]
def shutdown_retention_thread!(timeout: PotatoMesh::Config.federation_shutdown_timeout_seconds)
request_retention_shutdown!
return unless respond_to?(:settings)
return unless settings.respond_to?(:retention_thread)
thread = settings.retention_thread
if thread&.alive?
begin
thread.wakeup if thread.respond_to?(:wakeup)
rescue ThreadError
# Thread may not be sleeping; continue.
end
thread.join(timeout)
if thread.alive?
thread.kill
thread.join(0.1)
end
end
set(:retention_thread, nil) if respond_to?(:set)
end
# Install an +at_exit+ hook that tears down the retention worker on
# process termination. Idempotent — repeated calls are no-ops after
# the first install.
#
# The module is both +extend+ed and +include+d into the Sinatra
# application class, so this method may be invoked on either the class
# (extend path) or one of its instances (include path). The first
# line below normalises both entry points onto the class so the hook
# registers exactly once per process — without it, +include+-side
# invocations would each install their own +at_exit+ block and
# +shutdown_retention_thread!+ would run repeatedly on exit.
#
# @return [void]
def ensure_retention_shutdown_hook!
application = is_a?(Class) ? self : self.class
return application.ensure_retention_shutdown_hook! unless application.equal?(self)
installed = if respond_to?(:settings) && settings.respond_to?(:retention_shutdown_hook_installed)
settings.retention_shutdown_hook_installed
else
instance_variable_defined?(:@retention_shutdown_hook_installed) &&
@retention_shutdown_hook_installed
end
return if installed
if respond_to?(:set) && settings.respond_to?(:retention_shutdown_hook_installed=)
set(:retention_shutdown_hook_installed, true)
else
@retention_shutdown_hook_installed = true
end
at_exit do
begin
application.shutdown_retention_thread!
rescue StandardError
# Suppress shutdown errors during interpreter teardown.
end
end
end
end
end
end
@@ -317,6 +317,12 @@ module PotatoMesh
halt 400, { error: "bucketSeconds must be positive" }.to_json
end
# Clamp the requested window to the 28-day data-retention floor
# so no caller can reach beyond the API visibility cap by passing
# an oversized +windowSeconds+. The query layer repeats this
# clamp for defence in depth.
window_seconds = clamp_window_seconds(window_seconds)
bucket_count = (window_seconds.to_f / bucket_seconds).ceil
if bucket_count > PotatoMesh::App::Queries::MAX_QUERY_LIMIT
halt 400, { error: "bucketSeconds too small for requested window" }.to_json
+55 -1
View File
@@ -201,13 +201,67 @@ module PotatoMesh
# fragile (traces, neighbors, ingestors) and as the floor for every
# +/api/.../:id+ lookup so callers can backfill historical records that
# would otherwise fall outside the seven-day default applied to bulk
# endpoints.
# endpoints. This is also the absolute API-level visibility cap: no
# request may return data older than this window regardless of the
# +since+ or +windowSeconds+ parameters submitted.
#
# @return [Integer] seconds in twenty-eight days.
def four_weeks_seconds
28 * 24 * 60 * 60
end
# Hard retention window applied by the periodic purge job. Rows whose
# most recent activity timestamp is older than this duration are deleted
# from the database. The 28-day API visibility floor is enforced
# separately via {#four_weeks_seconds}, so the gap between the two
# windows preserves recoverable history without ever exposing it to API
# consumers.
#
# @return [Integer] seconds in 365 days.
def year_seconds
365 * 24 * 60 * 60
end
# Interval between consecutive retention purge passes.
#
# The retention worker wakes once per day to delete rows older than
# {#year_seconds}. A daily cadence keeps the working set bounded
# without holding write locks for long stretches.
#
# @return [Integer] seconds between purge cycles.
def retention_purge_interval_seconds
24 * 60 * 60
end
# Grace period applied before the first retention purge runs after boot.
#
# Mirrors the federation-announcer pattern: the daemon yields the
# request thread for a short delay so initial schema migrations and
# federation handshakes complete before a potentially long-running
# +DELETE+ ties up the database.
#
# @return [Integer] seconds to wait before the first purge.
def initial_retention_delay_seconds
30
end
# Substring marker that signals a node has opted out of being displayed
# in any user-facing surface. Operators include the marker anywhere in
# the node's short or long name; the API filters out any node whose
# display name contains this exact character (a single grapheme). Data
# is still ingested so deduplication and routing continue to work.
#
# @return [String] frozen single-character opt-out marker.
NODE_OPT_OUT_MARKER = "\u{1F6D1}".freeze
# Expose {NODE_OPT_OUT_MARKER} via a method so callers that prefer the
# module-function style stay consistent with the rest of the module.
#
# @return [String] frozen opt-out marker character.
def node_opt_out_marker
NODE_OPT_OUT_MARKER
end
# Default upper bound for accepted JSON payload sizes.
#
# @return [Integer] byte ceiling for HTTP request bodies.
+88
View File
@@ -1212,6 +1212,23 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(application_class.latest_node_update_timestamp).to be_nil
end
it "ignores opted-out nodes when computing the freshness hint" do
marker = PotatoMesh::Config.node_opt_out_marker
with_db do |db|
db.execute("DELETE FROM nodes")
db.execute(
"INSERT INTO nodes (node_id, long_name, last_heard) VALUES (?, ?, ?)",
["!visible", "Visible Node", 100],
)
db.execute(
"INSERT INTO nodes (node_id, long_name, last_heard) VALUES (?, ?, ?)",
["!silenced", "Silenced #{marker} Node", 500],
)
end
expect(application_class.latest_node_update_timestamp).to eq(100)
end
end
describe ".build_well_known_document" do
@@ -6642,6 +6659,65 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(ids).not_to include("!hidden")
end
it "filters opted-out nodes (\u{1F6D1} in name) from the nodes API" do
clear_database
now = reference_time.to_i
marker = PotatoMesh::Config.node_opt_out_marker
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
["!quiet001", "QT", "Quiet #{marker} Node", "TBEAM", "CLIENT", 1.0, now, now],
)
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
["!loud0001", "LD", "Loud Node", "TBEAM", "CLIENT", 1.0, now, now],
)
end
get "/api/nodes?limit=10"
expect(last_response).to be_ok
ids = JSON.parse(last_response.body).map { |row| row["node_id"] }
expect(ids).to include("!loud0001")
expect(ids).not_to include("!quiet001")
# Per-id lookup must also pretend the opted-out node does not exist.
get "/api/nodes/!quiet001"
expect(last_response.status).to eq(404)
end
it "still ingests opted-out node data even though the API hides it" do
clear_database
marker = PotatoMesh::Config.node_opt_out_marker
payload = {
"!silenced" => {
"num" => 0xdead0001,
"lastHeard" => reference_time.to_i,
"user" => {
"shortName" => "SL",
"longName" => "Silenced #{marker} Node",
},
},
}
post "/api/nodes", payload.to_json, auth_headers
expect(last_response).to be_ok
# The row exists in the database — opt-out is a display-time filter,
# not an ingestion-time refusal.
with_db(readonly: true) do |db|
row = db.execute("SELECT node_id, long_name FROM nodes WHERE node_id = ?", ["!silenced"]).first
expect(row).not_to be_nil
expect(row[1]).to include(marker)
end
# Bulk and per-id endpoints both omit the opted-out node.
get "/api/nodes"
ids = JSON.parse(last_response.body).map { |r| r["node_id"] }
expect(ids).not_to include("!silenced")
get "/api/nodes/!silenced"
expect(last_response.status).to eq(404)
end
it "removes the chat interface from the homepage" do
get "/"
@@ -7351,6 +7427,18 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response.status).to eq(400)
expect(JSON.parse(last_response.body)).to eq("error" => "bucketSeconds too small for requested window")
end
it "clamps windowSeconds to the 28-day visibility cap" do
# A 10-year window is well beyond four_weeks_seconds; the bucket size
# is selected so that under the natural 10-year window the bucket count
# would explode past MAX_QUERY_LIMIT and the route would return 400.
# When the cap kicks in correctly the request succeeds with HTTP 200.
huge_window = 10 * 365 * 24 * 60 * 60
bucket = PotatoMesh::Config.four_weeks_seconds / 100
get "/api/telemetry/aggregated?windowSeconds=#{huge_window}&bucketSeconds=#{bucket}"
expect(last_response).to be_ok
end
end
describe "GET /api/traces" do
+34
View File
@@ -657,6 +657,40 @@ RSpec.describe PotatoMesh::Config do
# @param values [Hash{String=>String, nil}] key/value pairs to set in ENV.
# @yield [] block executed while the overrides are active.
# @return [void]
describe ".year_seconds" do
it "matches 365 days expressed in seconds" do
expect(described_class.year_seconds).to eq(365 * 24 * 60 * 60)
end
it "is strictly larger than the 28-day visibility window" do
expect(described_class.year_seconds).to be > described_class.four_weeks_seconds
end
end
describe ".retention_purge_interval_seconds" do
it "defaults to running daily" do
expect(described_class.retention_purge_interval_seconds).to eq(24 * 60 * 60)
end
end
describe ".initial_retention_delay_seconds" do
it "yields long enough for boot work to finish without blocking startup" do
delay = described_class.initial_retention_delay_seconds
expect(delay).to be > 0
expect(delay).to be < described_class.retention_purge_interval_seconds
end
end
describe ".node_opt_out_marker" do
it "is the U+1F6D1 stop sign emoji" do
expect(described_class.node_opt_out_marker).to eq("\u{1F6D1}")
end
it "is exposed as a frozen constant" do
expect(described_class::NODE_OPT_OUT_MARKER).to be_frozen
end
end
def within_env(values)
original = {}
values.each do |key, value|
+53
View File
@@ -2259,6 +2259,59 @@ RSpec.describe PotatoMesh::App::Federation do
end
end
describe "federation counts honour the opt-out marker" do
around do |example|
Dir.mktmpdir("federation-optout-") do |dir|
db_path = File.join(dir, "mesh.db")
RSpec::Mocks.with_temporary_scope do
allow(PotatoMesh::Config).to receive(:db_path).and_return(db_path)
allow(PotatoMesh::Config).to receive(:db_busy_timeout_ms).and_return(5000)
db_helper = Object.new.extend(PotatoMesh::App::Database)
db_helper.init_db
db_helper.ensure_schema_upgrades
example.run
end
end
end
let(:marker) { PotatoMesh::Config.node_opt_out_marker }
let(:now) { Time.now.to_i }
it "excludes opted-out nodes from active_node_count_since" do
db = SQLite3::Database.new(PotatoMesh::Config.db_path)
db.execute(
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role, protocol) " \
"VALUES (?,?,?,?,?,?,?,?)",
["!visnode1", 0x10000001, "VN", "Visible", now, now, "CLIENT", "meshtastic"],
)
db.execute(
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role, protocol) " \
"VALUES (?,?,?,?,?,?,?,?)",
["!hidnode1", 0x10000002, "HN", "Hidden #{marker}", now, now, "CLIENT", "meshtastic"],
)
db.close
expect(federation_helpers.active_node_count_since(now - 60)).to eq(1)
end
it "excludes opted-out nodes from active_node_count_since_for_protocol" do
db = SQLite3::Database.new(PotatoMesh::Config.db_path)
db.execute(
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role, protocol) " \
"VALUES (?,?,?,?,?,?,?,?)",
["!mcvisible", 0x20000001, "MV", "MC Visible", now, now, "COMPANION", "meshcore"],
)
db.execute(
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role, protocol) " \
"VALUES (?,?,?,?,?,?,?,?)",
["!mchidden", 0x20000002, "MH", "MC #{marker} Hidden", now, now, "COMPANION", "meshcore"],
)
db.close
expect(federation_helpers.active_node_count_since_for_protocol(now - 60, "meshcore")).to eq(1)
end
end
describe ".federation_worker_pool" do
it "delegates to ensure_federation_worker_pool!" do
sentinel = Object.new
+30
View File
@@ -49,3 +49,33 @@ describe PotatoMesh::Logging do
end
end
end
# The Helpers module wraps PotatoMesh::Logging with severity-specific
# convenience methods. Each helper has the same shape, so a parameterised
# round-trip spec keeps coverage tight without duplicating boilerplate.
RSpec.describe PotatoMesh::App::Helpers do
let(:host) do
klass = Class.new
klass.include(PotatoMesh::App::Helpers)
klass.new
end
let(:logger) { instance_double(Logger) }
{
debug_log: :debug,
info_log: :info,
warn_log: :warn,
}.each do |helper, severity|
describe "##{helper}" do
it "forwards to PotatoMesh::Logging at :#{severity}" do
allow(PotatoMesh::Logging).to receive(:logger_for).with(host).and_return(logger)
expect(PotatoMesh::Logging).to receive(:log).with(
logger, severity, "hello", context: "test", foo: "bar",
)
host.public_send(helper, "hello", context: "test", foo: "bar")
end
end
end
end
+282
View File
@@ -878,4 +878,286 @@ RSpec.describe PotatoMesh::App::Queries do
expect(sql_fragment).to include("OR")
end
end
# ---------------------------------------------------------------------------
# opt-out helpers (🛑 in long/short name)
# ---------------------------------------------------------------------------
describe "#opt_out_marker_params" do
it "returns the marker twice so each LIKE expression has a bound value" do
params = queries.opt_out_marker_params
expect(params.length).to eq(2)
expect(params).to all(eq(PotatoMesh::Config.node_opt_out_marker))
end
end
describe "#opt_out_self_filter" do
it "is a negation of the OR-of-LIKE name predicate" do
fragment = queries.opt_out_self_filter
expect(fragment).to start_with("NOT ")
expect(fragment).to include("COALESCE(long_name")
expect(fragment).to include("COALESCE(short_name")
end
end
describe "#opt_out_node_id_filter" do
it "wraps the column lookup with NULL passthrough" do
fragment = queries.opt_out_node_id_filter("m.from_id")
expect(fragment).to include("m.from_id IS NULL")
expect(fragment).to include("m.from_id NOT IN")
expect(fragment).to include("SELECT node_id FROM nodes")
end
it "guards the inner subquery against NULL node_id values" do
# SQLite's `NOT IN (subquery returning NULL)` evaluates to UNKNOWN and
# would silently drop every row. The subquery must reject NULL ids.
fragment = queries.opt_out_node_id_filter("from_id")
expect(fragment).to include("node_id IS NOT NULL")
end
it "rejects column identifiers containing unsafe characters" do
expect { queries.opt_out_node_id_filter("from_id; DROP TABLE nodes--") }.to raise_error(ArgumentError, /unsafe column identifier/)
expect { queries.opt_out_node_id_filter("") }.to raise_error(ArgumentError, /unsafe column identifier/)
expect { queries.opt_out_node_id_filter(nil) }.to raise_error(ArgumentError, /unsafe column identifier/)
end
end
describe "#opt_out_node_num_filter" do
it "guards against NULL numeric IDs and skips nodes without num" do
fragment = queries.opt_out_node_num_filter("src")
expect(fragment).to include("src IS NULL")
expect(fragment).to include("src NOT IN")
expect(fragment).to include("num IS NOT NULL")
end
it "rejects column identifiers containing unsafe characters" do
expect { queries.opt_out_node_num_filter("src OR 1=1") }.to raise_error(ArgumentError, /unsafe column identifier/)
end
end
describe "#assert_safe_column_identifier!" do
it "accepts bare identifiers and dotted qualifiers" do
expect(queries.assert_safe_column_identifier!("node_id")).to eq("node_id")
expect(queries.assert_safe_column_identifier!("m.from_id")).to eq("m.from_id")
end
it "rejects anything else" do
["1bad", "a.b.c", "name`", "name with space", nil, 42].each do |bad|
expect { queries.assert_safe_column_identifier!(bad) }.to raise_error(ArgumentError, /unsafe column identifier/)
end
end
end
describe "#append_opt_out_filter" do
it "appends the SQL fragment and its two marker bind values" do
clauses = []
params = []
queries.append_opt_out_filter(clauses, params, queries.opt_out_self_filter)
expect(clauses.length).to eq(1)
expect(params.length).to eq(2)
end
end
describe "#clamp_window_seconds" do
it "returns nil for non-positive or nil input" do
expect(queries.clamp_window_seconds(nil)).to be_nil
expect(queries.clamp_window_seconds(0)).to be_nil
expect(queries.clamp_window_seconds(-1)).to be_nil
end
it "passes positive values through when within the 28-day cap" do
expect(queries.clamp_window_seconds(60)).to eq(60)
end
it "clamps oversized windows to four_weeks_seconds" do
huge = PotatoMesh::Config.four_weeks_seconds * 10
expect(queries.clamp_window_seconds(huge)).to eq(PotatoMesh::Config.four_weeks_seconds)
end
end
describe "opt-out filtering in read queries" do
let(:marker) { PotatoMesh::Config.node_opt_out_marker }
# Seed a visible node and an opted-out node sharing similar telemetry,
# message, position, neighbor, and trace footprints so each query helper
# can be checked for opt-out compliance from one fixture.
before do
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \
"VALUES (?,?,?,?,?,?,?)",
["!visible0", 0x00bb0001, "VIS", "Visible Node", now, now, "CLIENT"],
)
db.execute(
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \
"VALUES (?,?,?,?,?,?,?)",
["!optout01", 0x00bb0002, "OUT", "Hidden #{marker} Node", now, now, "CLIENT"],
)
db.execute(
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \
"VALUES (?,?,?,?,?,?,?)",
["!optshort", 0x00bb0003, "S#{marker}X", "Short Marker", now, now, "CLIENT"],
)
rx_iso = Time.at(now).utc.iso8601
db.execute(
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, text) VALUES (?,?,?,?,?,?,?)",
[10, now, rx_iso, "!visible0", "!ffffffff", 0, "from-visible"],
)
db.execute(
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, text) VALUES (?,?,?,?,?,?,?)",
[11, now, rx_iso, "!optout01", "!ffffffff", 0, "from-optout"],
)
db.execute(
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, text) VALUES (?,?,?,?,?,?,?)",
[12, now, rx_iso, "!visible0", "!optout01", 0, "to-optout"],
)
db.execute(
"INSERT INTO positions(id, rx_time, rx_iso, node_id, latitude, longitude) VALUES (?,?,?,?,?,?)",
[10, now, rx_iso, "!visible0", 52.0, 13.0],
)
db.execute(
"INSERT INTO positions(id, rx_time, rx_iso, node_id, latitude, longitude) VALUES (?,?,?,?,?,?)",
[11, now, rx_iso, "!optout01", 53.0, 14.0],
)
db.execute(
"INSERT INTO telemetry(id, rx_time, rx_iso, node_id, telemetry_type) VALUES (?,?,?,?,?)",
[10, now, rx_iso, "!visible0", "device"],
)
db.execute(
"INSERT INTO telemetry(id, rx_time, rx_iso, node_id, telemetry_type) VALUES (?,?,?,?,?)",
[11, now, rx_iso, "!optout01", "device"],
)
db.execute(
"INSERT INTO neighbors(node_id, neighbor_id, snr, rx_time) VALUES (?,?,?,?)",
["!visible0", "!optout01", 5.0, now],
)
db.execute(
"INSERT INTO traces(id, rx_time, rx_iso, src, dest) VALUES (?,?,?,?,?)",
[10, now, rx_iso, 0x00bb0001, 0xdeadbeef],
)
db.execute(
"INSERT INTO traces(id, rx_time, rx_iso, src, dest) VALUES (?,?,?,?,?)",
[11, now, rx_iso, 0x00bb0002, 0xdeadbeef],
)
db.execute(
"INSERT INTO trace_hops(trace_id, hop_index, node_id) VALUES (?,?,?)",
[10, 0, 0x00bb0002],
)
db.execute(
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version) VALUES (?,?,?,?)",
["!visible0", now, now, "1.0"],
)
db.execute(
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version) VALUES (?,?,?,?)",
["!optout01", now, now, "1.0"],
)
end
end
it "excludes opted-out nodes from query_nodes by long_name marker" do
ids = queries.query_nodes(50).map { |row| row["node_id"] }
expect(ids).to include("!visible0")
expect(ids).not_to include("!optout01")
end
it "excludes opted-out nodes from query_nodes by short_name marker" do
ids = queries.query_nodes(50).map { |row| row["node_id"] }
expect(ids).not_to include("!optshort")
end
it "returns no row when querying a single opted-out node by id" do
rows = queries.query_nodes(10, node_ref: "!optout01")
expect(rows).to be_empty
end
it "drops chat lines whose sender or recipient is opted out" do
texts = queries.query_messages(50, include_encrypted: true).map { |r| r["text"] }
expect(texts).to include("from-visible")
expect(texts).not_to include("from-optout")
expect(texts).not_to include("to-optout")
end
it "hides position rows for opted-out nodes" do
ids = queries.query_positions(50).map { |r| r["node_id"] }
expect(ids).to include("!visible0")
expect(ids).not_to include("!optout01")
end
it "hides telemetry rows for opted-out nodes" do
ids = queries.query_telemetry(50).map { |r| r["node_id"] }
expect(ids).to include("!visible0")
expect(ids).not_to include("!optout01")
end
it "hides neighbour relationships involving opted-out nodes" do
rows = queries.query_neighbors(50)
expect(rows).to be_empty
end
it "hides traces whose src or dest is opted out" do
ids = queries.query_traces(50).map { |r| r["id"] }
expect(ids).to include(10)
expect(ids).not_to include(11)
end
it "scrubs opted-out hop ids from surviving traces" do
rows = queries.query_traces(50)
trace_10 = rows.find { |r| r["id"] == 10 }
expect(trace_10).not_to be_nil
# Hop 0x00bb0002 belongs to the opted-out node and must be filtered out
# of the hop list even though the parent trace is visible.
expect(trace_10["hops"]).to be_nil
end
it "hides ingestor heartbeats for opted-out nodes" do
ids = queries.query_ingestors(50).map { |r| r["node_id"] }
expect(ids).to include("!visible0")
expect(ids).not_to include("!optout01")
end
it "excludes opted-out nodes from active stats counters" do
stats = queries.query_active_node_stats(now: now)
# Only "!visible0" is visible — opted-out and short-marker nodes are dropped.
expect(stats["day"]).to eq(1)
expect(stats["week"]).to eq(1)
expect(stats["month"]).to eq(1)
end
end
describe "#query_active_node_stats" do
it "caps the month bucket at four_weeks_seconds (28 days)" do
twenty_nine_days_ago = now - (29 * 24 * 60 * 60)
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, num, short_name, last_heard, first_heard, role) VALUES (?,?,?,?,?,?)",
["!29dayago", 0x29000001, "OLD", twenty_nine_days_ago, twenty_nine_days_ago, "CLIENT"],
)
end
stats = queries.query_active_node_stats(now: now)
# 28-day cap means this 29-day-old row falls outside the "month" bucket.
expect(stats["month"]).to eq(0)
end
end
describe "#query_telemetry_buckets" do
it "clamps oversized window_seconds to the 28-day visibility cap" do
huge_window = PotatoMesh::Config.four_weeks_seconds * 50
rows = queries.query_telemetry_buckets(
window_seconds: huge_window,
bucket_seconds: PotatoMesh::App::Queries::DEFAULT_TELEMETRY_BUCKET_SECONDS,
)
# Even with a 50× window the implementation must not look beyond 28 days;
# the returned bucket set therefore matches a 28-day query exactly.
reference = queries.query_telemetry_buckets(
window_seconds: PotatoMesh::Config.four_weeks_seconds,
bucket_seconds: PotatoMesh::App::Queries::DEFAULT_TELEMETRY_BUCKET_SECONDS,
)
expect(rows.length).to eq(reference.length)
end
end
end
+595
View File
@@ -0,0 +1,595 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# frozen_string_literal: true
require "spec_helper"
require "sqlite3"
# Specs covering the 365-day data retention purge implemented in
# +PotatoMesh::App::Retention+. The module is wired into the running
# Sinatra application; the tests below mix it into a bare host class so
# the retention behaviour can be exercised without spinning up the full
# web stack.
RSpec.describe PotatoMesh::App::Retention do
let(:harness_class) do
Class.new do
extend PotatoMesh::App::Retention
extend PotatoMesh::App::Database
extend PotatoMesh::App::Helpers
class << self
# Provide read-write database connections so the retention purge can
# execute its DELETE statements.
def open_database(readonly: false)
db = SQLite3::Database.new(PotatoMesh::Config.db_path, readonly: readonly)
db.busy_timeout = PotatoMesh::Config.db_busy_timeout_ms
db.execute("PRAGMA foreign_keys = ON")
db
end
# Capture debug/warn log entries so tests can inspect what the
# retention worker reported.
attr_reader :log_events
def warn_log(message, context:, **metadata)
(@log_events ||= []) << {
level: :warn, message: message, context: context, metadata: metadata,
}
end
def info_log(message, context:, **metadata)
(@log_events ||= []) << {
level: :info, message: message, context: context, metadata: metadata,
}
end
def debug_log(message, context:, **metadata)
(@log_events ||= []) << {
level: :debug, message: message, context: context, metadata: metadata,
}
end
def reset_logs!
@log_events = []
end
end
end
end
let(:now) { Time.now.to_i }
around do |example|
Dir.mktmpdir("retention-spec-") do |dir|
db_path = File.join(dir, "mesh.db")
RSpec::Mocks.with_temporary_scope do
allow(PotatoMesh::Config).to receive(:db_path).and_return(db_path)
allow(PotatoMesh::Config).to receive(:db_busy_timeout_ms).and_return(5000)
harness_class.init_db
harness_class.ensure_schema_upgrades
harness_class.reset_logs!
example.run
end
end
end
# Helper to insert a fresh row plus a stale row into each retention target.
# +ages+ map column → seconds-old-relative-to-+now+.
def seed_retention_dataset(now_ts)
db = SQLite3::Database.new(PotatoMesh::Config.db_path)
db.execute("PRAGMA foreign_keys = ON")
fresh = now_ts - 100
stale = now_ts - PotatoMesh::Config.year_seconds - 86_400 # 1 day past the cutoff
db.execute(
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) VALUES (?,?,?,?,?,?,?)",
["!fffff001", 0xfffff001, "FR", "Fresh Node", fresh, fresh, "CLIENT"],
)
db.execute(
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) VALUES (?,?,?,?,?,?,?)",
["!aaaaa001", 0xaaaaa001, "ST", "Stale Node", stale, stale, "CLIENT"],
)
db.execute(
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, text) VALUES (?,?,?,?,?,?)",
[1, fresh, Time.at(fresh).utc.iso8601, "!fffff001", "!ffffffff", "fresh"],
)
db.execute(
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, text) VALUES (?,?,?,?,?,?)",
[2, stale, Time.at(stale).utc.iso8601, "!fffff001", "!ffffffff", "stale"],
)
db.execute(
"INSERT INTO positions(id, rx_time, rx_iso, node_id, latitude, longitude) VALUES (?,?,?,?,?,?)",
[1, fresh, Time.at(fresh).utc.iso8601, "!fffff001", 52.0, 13.0],
)
db.execute(
"INSERT INTO positions(id, rx_time, rx_iso, node_id, latitude, longitude) VALUES (?,?,?,?,?,?)",
[2, stale, Time.at(stale).utc.iso8601, "!fffff001", 53.0, 14.0],
)
db.execute(
"INSERT INTO telemetry(id, rx_time, rx_iso, node_id, telemetry_type) VALUES (?,?,?,?,?)",
[1, fresh, Time.at(fresh).utc.iso8601, "!fffff001", "device"],
)
db.execute(
"INSERT INTO telemetry(id, rx_time, rx_iso, node_id, telemetry_type) VALUES (?,?,?,?,?)",
[2, stale, Time.at(stale).utc.iso8601, "!fffff001", "device"],
)
db.execute(
"INSERT INTO neighbors(node_id, neighbor_id, snr, rx_time) VALUES (?,?,?,?)",
["!fffff001", "!aaaaa001", 4.0, fresh],
)
db.execute(
"INSERT INTO traces(id, rx_time, rx_iso, src, dest) VALUES (?,?,?,?,?)",
[1, fresh, Time.at(fresh).utc.iso8601, 0xaaaaaaaa, 0xbbbbbbbb],
)
db.execute(
"INSERT INTO traces(id, rx_time, rx_iso, src, dest) VALUES (?,?,?,?,?)",
[2, stale, Time.at(stale).utc.iso8601, 0xaaaaaaaa, 0xbbbbbbbb],
)
db.execute(
"INSERT INTO trace_hops(trace_id, hop_index, node_id) VALUES (?,?,?)",
[2, 0, 0xcccccccc],
)
db.execute(
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version) VALUES (?,?,?,?)",
["!fffff001", fresh, fresh, "1.0"],
)
db.execute(
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version) VALUES (?,?,?,?)",
["!aaaaa001", stale, stale, "1.0"],
)
ensure
db&.close
end
describe ".purge_old_data!" do
it "removes rows older than 365 days from every retention target" do
seed_retention_dataset(now)
removed = harness_class.purge_old_data!(now: now)
db = SQLite3::Database.new(PotatoMesh::Config.db_path)
begin
node_ids = db.execute("SELECT node_id FROM nodes ORDER BY node_id").flatten
expect(node_ids).to include("!fffff001")
expect(node_ids).not_to include("!aaaaa001")
msg_ids = db.execute("SELECT id FROM messages ORDER BY id").flatten
expect(msg_ids).to eq([1])
pos_ids = db.execute("SELECT id FROM positions ORDER BY id").flatten
expect(pos_ids).to eq([1])
tel_ids = db.execute("SELECT id FROM telemetry ORDER BY id").flatten
expect(tel_ids).to eq([1])
trace_ids = db.execute("SELECT id FROM traces ORDER BY id").flatten
expect(trace_ids).to eq([1])
# Cascading DELETE on traces removes the dependent trace_hops row.
hop_count = db.get_first_value("SELECT COUNT(*) FROM trace_hops")
expect(hop_count).to eq(0)
ingestor_ids = db.execute("SELECT node_id FROM ingestors ORDER BY node_id").flatten
expect(ingestor_ids).to eq(["!fffff001"])
# Cascading DELETE on nodes removes the neighbour relationship that
# referenced the deleted stale node.
neighbor_count = db.get_first_value("SELECT COUNT(*) FROM neighbors")
expect(neighbor_count).to eq(0)
ensure
db&.close
end
expect(removed["nodes"]).to eq(1)
expect(removed["messages"]).to eq(1)
expect(removed["positions"]).to eq(1)
expect(removed["telemetry"]).to eq(1)
expect(removed["traces"]).to eq(1)
expect(removed["ingestors"]).to eq(1)
end
it "keeps every row when the database is empty" do
removed = harness_class.purge_old_data!(now: now)
expect(removed.values.uniq).to eq([0])
end
it "logs the successful purge at info level" do
# Promoted from debug so retention activity is visible at the default
# log verbosity; specs lock the level in so a future refactor cannot
# silently demote it.
harness_class.purge_old_data!(now: now)
entry = harness_class.log_events.find do |e|
e[:context] == "retention.purge" && e[:level] == :info
end
expect(entry).not_to be_nil
end
it "logs and recovers from unexpected SQLite errors" do
# Force a transient SQLite error by closing the database file mid-call.
allow(harness_class).to receive(:open_database).and_raise(SQLite3::Exception, "boom")
expect(harness_class.purge_old_data!(now: now)).to eq({})
failure = harness_class.log_events.find { |e| e[:level] == :warn }
expect(failure).not_to be_nil
expect(failure[:context]).to eq("retention.purge")
end
it "leaves rows with NULL retention timestamps untouched" do
# A node whose last_heard is NULL has no activity timestamp at all,
# so the purge cannot prove it is older than the cutoff and must
# leave it alone. The +IS NOT NULL+ guard in the DELETE protects
# against accidentally wiping every such row.
db = SQLite3::Database.new(PotatoMesh::Config.db_path)
begin
db.execute(
"INSERT INTO nodes(node_id, num, short_name, long_name, first_heard, role) VALUES (?,?,?,?,?,?)",
["!nullnode", 0xdeadbeef, "NN", "Null Node", now - 100, "CLIENT"],
)
ensure
db.close
end
harness_class.purge_old_data!(now: now)
db = SQLite3::Database.new(PotatoMesh::Config.db_path, readonly: true)
begin
expect(db.execute("SELECT node_id FROM nodes").flatten).to include("!nullnode")
ensure
db.close
end
end
end
describe ".retention_sleep_with_shutdown" do
it "returns true after slicing through the requested duration" do
slept = []
allow(Kernel).to receive(:sleep) { |seconds| slept << seconds }
# 0.5 s with 0.2 s slices means three iterations: 0.2, 0.2, 0.1.
result = harness_class.retention_sleep_with_shutdown(0.5)
expect(result).to be(true)
expect(slept.sum).to be_within(1e-9).of(0.5)
expect(slept).to all(be <= 0.2 + 1e-9)
expect(slept.length).to be >= 2
end
it "returns true immediately when the duration is non-positive" do
expect(Kernel).not_to receive(:sleep)
expect(harness_class.retention_sleep_with_shutdown(0.0)).to be(true)
end
it "returns false immediately when shutdown is already requested" do
allow(harness_class).to receive(:retention_shutdown_requested?).and_return(true)
result = harness_class.retention_sleep_with_shutdown(5.0)
expect(result).to be(false)
end
it "bails out mid-sleep when shutdown is requested between slices" do
slept = []
allow(Kernel).to receive(:sleep) { |seconds| slept << seconds }
call_count = 0
allow(harness_class).to receive(:retention_shutdown_requested?) do
call_count += 1
call_count > 2 # first two checks: keep sleeping; then shutdown.
end
result = harness_class.retention_sleep_with_shutdown(5.0)
expect(result).to be(false)
# Should not have slept the entire 5 s — the loop terminates early.
expect(slept.sum).to be < 1.0
end
end
describe ".retention_shutdown_requested?" do
it "returns false when settings are not available" do
expect(harness_class.retention_shutdown_requested?).to be(false)
end
end
describe ".retention_thread_loop" do
it "exits without purging when shutdown is requested during the initial delay" do
allow(harness_class).to receive(:retention_sleep_with_shutdown).and_return(false)
expect(harness_class).not_to receive(:purge_old_data!)
harness_class.retention_thread_loop
end
it "purges once and exits when the post-iteration sleep is interrupted" do
call_count = 0
allow(harness_class).to receive(:retention_sleep_with_shutdown) do |_seconds|
call_count += 1
call_count == 1 # only the initial delay returns true; the next sleep
# signals shutdown and the loop must terminate.
end
expect(harness_class).to receive(:purge_old_data!).once.and_return({})
harness_class.retention_thread_loop
end
it "tolerates purge_old_data! returning an empty hash on error" do
# purge_old_data! handles its own errors and returns {} — the loop
# should treat that as a normal outcome and proceed to the sleep step.
call_count = 0
allow(harness_class).to receive(:retention_sleep_with_shutdown) do |_seconds|
call_count += 1
call_count == 1
end
expect(harness_class).to receive(:purge_old_data!).once.and_return({})
expect { harness_class.retention_thread_loop }.not_to raise_error
end
end
describe ".purge_old_data! return shape" do
it "produces an integer entry for every retention target" do
removed = harness_class.purge_old_data!(now: now)
described_class::RETENTION_TARGETS.each do |(table, _column)|
expect(removed).to have_key(table)
expect(removed[table]).to be_a(Integer)
end
end
end
# ---------------------------------------------------------------------------
# Shutdown plumbing — exercised with a Sinatra-shaped settings double so the
# respond_to? branches inside the retention module are followed end-to-end.
# ---------------------------------------------------------------------------
describe "shutdown lifecycle helpers" do
# A Sinatra-shaped settings double exposing every attribute the retention
# module's respond_to? checks look up.
settings_struct = Struct.new(
:retention_thread,
:retention_shutdown_requested,
:retention_shutdown_hook_installed,
)
let(:host_class) do
stx = settings_struct
klass = Class.new do
extend PotatoMesh::App::Retention
end
klass.define_singleton_method(:settings) do
@settings ||= stx.new
end
klass.define_singleton_method(:set) do |key, value|
writer = "#{key}="
raise ArgumentError, "unsupported setting #{key}" unless settings.respond_to?(writer)
settings.public_send(writer, value)
end
klass.define_singleton_method(:debug_log) { |*| }
klass.define_singleton_method(:warn_log) { |*| }
klass
end
describe ".start_retention_thread!" do
it "spawns a worker, stores it on settings, and returns the thread" do
# Replace the loop body with a brief sleep so the thread exits quickly
# rather than entering the real production loop.
allow(host_class).to receive(:retention_thread_loop) { sleep(0.01) }
allow(host_class).to receive(:ensure_retention_shutdown_hook!)
thread = host_class.start_retention_thread!
expect(thread).to be_a(Thread)
expect(host_class.settings.retention_thread).to be(thread)
thread.join(1)
end
it "returns the existing thread when one is still alive" do
allow(host_class).to receive(:ensure_retention_shutdown_hook!)
# An infinite sleep stands in for a long-lived worker.
existing = Thread.new { sleep }
host_class.settings.retention_shutdown_requested = false
host_class.set(:retention_thread, existing)
result = host_class.start_retention_thread!
expect(result).to be(existing)
ensure
existing&.kill
existing&.join(0.1)
end
end
describe ".retention_shutdown_requested?" do
it "is false when no shutdown has been requested" do
host_class.settings.retention_shutdown_requested = nil
expect(host_class.retention_shutdown_requested?).to be(false)
end
it "is true once a shutdown has been requested" do
host_class.settings.retention_shutdown_requested = true
expect(host_class.retention_shutdown_requested?).to be(true)
end
it "is false when settings lack the shutdown attribute" do
bare = Class.new { extend PotatoMesh::App::Retention }
bare_struct = Struct.new(:other)
bare.define_singleton_method(:settings) { @settings ||= bare_struct.new }
expect(bare.retention_shutdown_requested?).to be(false)
end
end
describe ".request_retention_shutdown! / .clear_retention_shutdown_request!" do
it "flips the settings flag on and off" do
host_class.request_retention_shutdown!
expect(host_class.settings.retention_shutdown_requested).to be(true)
host_class.clear_retention_shutdown_request!
expect(host_class.settings.retention_shutdown_requested).to be(false)
end
end
describe ".shutdown_retention_thread!" do
it "is a no-op when no thread has been registered" do
host_class.set(:retention_thread, nil)
expect { host_class.shutdown_retention_thread!(timeout: 0.05) }.not_to raise_error
end
it "wakes and joins a sleeping worker thread" do
thread = Thread.new { sleep }
host_class.set(:retention_thread, thread)
host_class.shutdown_retention_thread!(timeout: 0.5)
expect(thread).not_to be_alive
expect(host_class.settings.retention_thread).to be_nil
end
it "swallows ThreadError when wakeup raises" do
thread = Thread.new { sleep }
# Force wakeup to raise — the rescue inside shutdown_retention_thread!
# must swallow it and still join the thread.
allow(thread).to receive(:wakeup).and_raise(ThreadError, "dead"); allow(thread).to receive(:respond_to?).and_call_original
host_class.set(:retention_thread, thread)
expect { host_class.shutdown_retention_thread!(timeout: 0.5) }.not_to raise_error
ensure
thread&.kill
thread&.join(0.1)
end
it "force-kills the worker when join times out" do
# A purely CPU-bound thread won't respond to wakeup, so join(timeout)
# will time out and the kill branch must take over.
thread = Thread.new { loop { } }
host_class.set(:retention_thread, thread)
host_class.shutdown_retention_thread!(timeout: 0.05)
expect(thread).not_to be_alive
end
end
describe ".ensure_retention_shutdown_hook!" do
it "installs the hook exactly once when settings carry the flag" do
# First call flips the flag; second call returns early.
expect(host_class).to receive(:at_exit).once
host_class.ensure_retention_shutdown_hook!
host_class.ensure_retention_shutdown_hook!
expect(host_class.settings.retention_shutdown_hook_installed).to be(true)
end
it "delegates instance invocations to the host class" do
allow(host_class).to receive(:at_exit)
host_class.ensure_retention_shutdown_hook!
instance = host_class.new
# No raise — the instance entry-point should redirect to the class.
expect { instance.ensure_retention_shutdown_hook! }.not_to raise_error
end
it "falls back to an instance variable when settings lack the flag" do
# Build a class whose settings double has no
# retention_shutdown_hook_installed accessor, exercising the ivar
# branch in ensure_retention_shutdown_hook!.
bare_settings = Struct.new(:retention_thread)
bare = Class.new { extend PotatoMesh::App::Retention }
bare.define_singleton_method(:settings) { @settings ||= bare_settings.new }
bare.define_singleton_method(:set) { |*| }
bare.define_singleton_method(:shutdown_retention_thread!) { |*| }
allow(bare).to receive(:at_exit)
bare.ensure_retention_shutdown_hook!
bare.ensure_retention_shutdown_hook!
expect(bare.instance_variable_get(:@retention_shutdown_hook_installed)).to be(true)
end
it "registers an at_exit hook that calls shutdown_retention_thread!" do
captured = nil
allow(host_class).to receive(:at_exit) { |&block| captured = block }
host_class.ensure_retention_shutdown_hook!
expect(captured).not_to be_nil
expect(host_class).to receive(:shutdown_retention_thread!)
captured.call
end
it "swallows errors raised by shutdown_retention_thread! in the at_exit hook" do
captured = nil
allow(host_class).to receive(:at_exit) { |&block| captured = block }
host_class.ensure_retention_shutdown_hook!
allow(host_class).to receive(:shutdown_retention_thread!).and_raise(StandardError, "boom")
expect { captured.call }.not_to raise_error
end
end
end
# ---------------------------------------------------------------------------
# retention_worker_active? also has a host-class branch that prefers the
# local +test_environment?+ helper. Exercise it to lock in that fallback.
# ---------------------------------------------------------------------------
describe ".start_retention_worker_if_active!" do
it "spawns the worker when retention_worker_active? is true" do
klass = Class.new { extend PotatoMesh::App::Retention }
klass.define_singleton_method(:retention_worker_active?) { true }
klass.define_singleton_method(:clear_retention_shutdown_request!) { }
klass.define_singleton_method(:debug_log) { |*| }
sentinel = Object.new
allow(klass).to receive(:start_retention_thread!).and_return(sentinel)
expect(klass.start_retention_worker_if_active!).to be(sentinel)
end
it "skips the worker and logs a debug message when inactive" do
klass = Class.new { extend PotatoMesh::App::Retention }
klass.define_singleton_method(:retention_worker_active?) { false }
klass.define_singleton_method(:clear_retention_shutdown_request!) { }
captured = []
klass.define_singleton_method(:debug_log) do |message, **metadata|
captured << [message, metadata]
end
expect(klass).not_to receive(:start_retention_thread!)
expect(klass.start_retention_worker_if_active!).to be_nil
expect(captured.first.first).to eq("Retention worker disabled")
end
end
describe ".retention_worker_active?" do
# The retention module relies on the Helpers#test_environment? predicate,
# which is included into Object at boot time. Both branches are
# exercised by toggling RACK_ENV.
let(:host) { Class.new { extend PotatoMesh::App::Retention } }
around do |example|
original = ENV["RACK_ENV"]
example.run
ensure
ENV["RACK_ENV"] = original
end
it "returns false when RACK_ENV is test" do
ENV["RACK_ENV"] = "test"
expect(host.retention_worker_active?).to be(false)
end
it "returns true when RACK_ENV is anything else" do
ENV["RACK_ENV"] = "production"
expect(host.retention_worker_active?).to be(true)
end
end
end