diff --git a/web/lib/potato_mesh/application/instances.rb b/web/lib/potato_mesh/application/instances.rb index c034448..166d7e0 100644 --- a/web/lib/potato_mesh/application/instances.rb +++ b/web/lib/potato_mesh/application/instances.rb @@ -158,7 +158,8 @@ module PotatoMesh end # Fetch all instance rows ready to be served by the API while handling - # malformed rows gracefully. + # malformed rows gracefully. The dataset is restricted to records updated + # within the rolling window defined by PotatoMesh::Config.week_seconds. # # @return [Array] list of cleaned instance payloads. def load_instances_for_api @@ -166,22 +167,30 @@ module PotatoMesh db = open_database(readonly: true) db.results_as_hash = true + now = Time.now.to_i + min_last_update_time = now - PotatoMesh::Config.week_seconds + sql = <<~SQL + SELECT id, domain, pubkey, name, version, channel, frequency, + latitude, longitude, last_update_time, is_private, signature + FROM instances + WHERE domain IS NOT NULL AND TRIM(domain) != '' + AND pubkey IS NOT NULL AND TRIM(pubkey) != '' + AND last_update_time IS NOT NULL AND last_update_time >= ? + ORDER BY LOWER(domain) + SQL + rows = with_busy_retry do - db.execute( - <<~SQL - SELECT id, domain, pubkey, name, version, channel, frequency, - latitude, longitude, last_update_time, is_private, signature - FROM instances - WHERE domain IS NOT NULL AND TRIM(domain) != '' - AND pubkey IS NOT NULL AND TRIM(pubkey) != '' - ORDER BY LOWER(domain) - SQL - ) + db.execute(sql, min_last_update_time) end rows.each_with_object([]) do |row, memo| normalized = normalize_instance_row(row) - memo << normalized if normalized + next unless normalized + + last_update_time = normalized["lastUpdateTime"] + next unless last_update_time.is_a?(Integer) && last_update_time >= min_last_update_time + + memo << normalized end rescue SQLite3::Exception => e warn_log( diff --git a/web/spec/instances_spec.rb b/web/spec/instances_spec.rb new file mode 100644 index 0000000..4d06bd5 --- /dev/null +++ b/web/spec/instances_spec.rb @@ -0,0 +1,99 @@ +# Copyright (C) 2025 l5yth +# +# 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" + +RSpec.describe PotatoMesh::App::Instances do + let(:application_class) { PotatoMesh::Application } + let(:week_seconds) { PotatoMesh::Config.week_seconds } + + # Execute the provided block with a configured SQLite connection. + # + # @param readonly [Boolean] whether the connection should be read-only. + # @yieldparam db [SQLite3::Database] configured database handle. + # @return [void] + def with_db(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") + yield db + ensure + db&.close + end + + before do + FileUtils.mkdir_p(File.dirname(PotatoMesh::Config.db_path)) + application_class.init_db unless application_class.db_schema_present? + with_db do |db| + db.execute("DELETE FROM instances") + end + end + + describe ".load_instances_for_api" do + it "only returns instances updated within the configured rolling window" do + fixed_time = Time.utc(2025, 1, 15, 12, 0, 0) + allow(Time).to receive(:now).and_return(fixed_time) + + application_class.ensure_self_instance_record! + + recent_timestamp = fixed_time.to_i - (week_seconds / 2) + stale_timestamp = fixed_time.to_i - week_seconds - 60 + + with_db do |db| + db.execute( + "INSERT INTO instances (id, domain, pubkey, last_update_time, is_private) VALUES (?, ?, ?, ?, ?)", + [ + "recent-instance", + "recent.mesh.test", + PotatoMesh::Application::INSTANCE_PUBLIC_KEY_PEM, + recent_timestamp, + 0, + ], + ) + db.execute( + "INSERT INTO instances (id, domain, pubkey, last_update_time, is_private) VALUES (?, ?, ?, ?, ?)", + [ + "stale-instance", + "stale.mesh.test", + PotatoMesh::Application::INSTANCE_PUBLIC_KEY_PEM, + stale_timestamp, + 0, + ], + ) + db.execute( + "INSERT INTO instances (id, domain, pubkey, is_private) VALUES (?, ?, ?, ?)", + [ + "missing-instance", + "missing.mesh.test", + PotatoMesh::Application::INSTANCE_PUBLIC_KEY_PEM, + 0, + ], + ) + end + + payload = application_class.load_instances_for_api + domains = payload.map { |row| row["domain"] } + lower_bound = fixed_time.to_i - week_seconds + + expect(domains).to include("recent.mesh.test") + expect(domains).to include(application_class.app_constant(:INSTANCE_DOMAIN)) + expect(domains).not_to include("stale.mesh.test") + expect(domains).not_to include("missing.mesh.test") + expect(payload.all? { |row| row["lastUpdateTime"] >= lower_bound }).to be(true) + end + end +end