diff --git a/README.md b/README.md index e45a6f55..a9b4ec60 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,8 @@ The web app can be configured with environment variables (defaults shown): | `DEBUG` | `0` | Set to `1` for verbose logging in the web and ingestor services. | | `FEDERATION` | `1` | Set to `1` to announce your instance and crawl peers, or `0` to disable federation. Private mode overrides this. | | `PRIVATE` | `0` | Set to `1` to hide the chat UI, disable message APIs, and exclude hidden clients from public listings. | +| `INGESTOR_MANAGEMENT` | `0` | Set to `1` to enable ingestor management features for community contributors. | +| `ADMIN_TOKEN` | _unset_ | Token required to access ingestor management admin endpoints. | The application derives SEO-friendly document titles, descriptions, and social preview tags from these existing configuration values and reuses the bundled @@ -144,6 +146,53 @@ The web app contains an API: The `API_TOKEN` environment variable must be set to a non-empty value and match the token supplied in the `Authorization` header for `POST` requests. +### Ingestor Management + +When `INGESTOR_MANAGEMENT=1` is enabled, instance administrators can register +external ingestors with individual API keys instead of sharing the main +`API_TOKEN`. This allows community members to contribute data from their own +Meshtastic nodes while maintaining control over access. + +Each ingestor registration tracks: + +* **API Key**: Unique authentication token for the ingestor +* **Node ID**: Associated Meshtastic node identifier +* **Version**: Ingestor software version (reported automatically) +* **Last Request**: Timestamp of the most recent data submission +* **Contact Info**: Email and/or Matrix username for the contributor + +#### Admin API Endpoints + +All admin endpoints require `ADMIN_TOKEN` in the `Authorization` header: + +| Method | Endpoint | Description | +| --- | --- | --- | +| `GET` | `/admin/ingestors` | List all registered ingestors | +| `GET` | `/admin/ingestors/:id` | Get details for a specific ingestor | +| `POST` | `/admin/ingestors` | Create a new ingestor registration | +| `PATCH` | `/admin/ingestors/:id` | Update ingestor details | +| `POST` | `/admin/ingestors/:id/regenerate-key` | Generate a new API key | +| `POST` | `/admin/ingestors/:id/deactivate` | Disable an ingestor | +| `POST` | `/admin/ingestors/:id/reactivate` | Re-enable an ingestor | +| `DELETE` | `/admin/ingestors/:id` | Permanently remove an ingestor | + +#### Creating an Ingestor + +```bash +curl -X POST https://your-instance.example/admin/ingestors \ + -H "Authorization: Bearer YOUR_ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Community Node Berlin", + "node_id": "!abc12345", + "contact_email": "contributor@example.com", + "contact_matrix": "@user:matrix.org" + }' +``` + +The response includes the generated API key. **Store it securely** - it will +only be shown once (regeneration is possible but invalidates the previous key). + ### Monitoring PotatoMesh ships with a Prometheus exporter mounted at `/metrics`. Consult diff --git a/data/ingestors.sql b/data/ingestors.sql new file mode 100644 index 00000000..5e7f6ee1 --- /dev/null +++ b/data/ingestors.sql @@ -0,0 +1,33 @@ +-- 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. + +PRAGMA journal_mode=WAL; + +CREATE TABLE IF NOT EXISTS ingestors ( + id TEXT PRIMARY KEY, + api_key TEXT NOT NULL UNIQUE, + name TEXT, + node_id TEXT, + contact_email TEXT, + contact_matrix TEXT, + version TEXT, + last_request_time INTEGER, + request_count INTEGER DEFAULT 0, + created_at INTEGER NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT 1 +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_ingestors_api_key ON ingestors(api_key); +CREATE INDEX IF NOT EXISTS idx_ingestors_node_id ON ingestors(node_id); +CREATE INDEX IF NOT EXISTS idx_ingestors_is_active ON ingestors(is_active); diff --git a/data/mesh_ingestor/queue.py b/data/mesh_ingestor/queue.py index d6010887..69560397 100644 --- a/data/mesh_ingestor/queue.py +++ b/data/mesh_ingestor/queue.py @@ -101,6 +101,7 @@ def _post_json( *, instance: str | None = None, api_token: str | None = None, + ingestor_version: str | None = None, ) -> None: """Send a JSON payload to the configured web API. @@ -109,12 +110,20 @@ def _post_json( payload: JSON-serialisable body to transmit. instance: Optional override for :data:`config.INSTANCE`. api_token: Optional override for :data:`config.API_TOKEN`. + ingestor_version: Optional version string to report in headers. """ if instance is None: instance = config.INSTANCE if api_token is None: api_token = config.API_TOKEN + if ingestor_version is None: + try: + from data import VERSION + + ingestor_version = VERSION + except ImportError: + ingestor_version = None if not instance: return @@ -132,6 +141,8 @@ def _post_json( } if api_token: headers["Authorization"] = f"Bearer {api_token}" + if ingestor_version: + headers["X-Ingestor-Version"] = ingestor_version req = urllib.request.Request( url, diff --git a/tests/test_mesh.py b/tests/test_mesh.py index 5aae2122..fa8cad19 100644 --- a/tests/test_mesh.py +++ b/tests/test_mesh.py @@ -1482,6 +1482,68 @@ def read(self): assert mesh.json.loads(req.data.decode("utf-8")) == {"hello": "world"} +def test_post_json_sends_ingestor_version_header(mesh_module, monkeypatch): + mesh = mesh_module + monkeypatch.setattr(mesh, "INSTANCE", "https://example.test") + monkeypatch.setattr(mesh, "API_TOKEN", "secret") + + captured = {} + + def fake_urlopen(req, timeout=0): + captured["req"] = req + + class DummyResponse: + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def read(self): + return b"ok" + + return DummyResponse() + + monkeypatch.setattr(mesh.urllib.request, "urlopen", fake_urlopen) + + mesh._post_json("/api/test", {"hello": "world"}, ingestor_version="1.2.3") + + req = captured["req"] + assert req.get_header("X-ingestor-version") == "1.2.3" + + +def test_post_json_omits_ingestor_version_when_unavailable(mesh_module, monkeypatch): + mesh = mesh_module + monkeypatch.setattr(mesh, "INSTANCE", "https://example.test") + monkeypatch.setattr(mesh, "API_TOKEN", "secret") + + captured = {} + + def fake_urlopen(req, timeout=0): + captured["req"] = req + + class DummyResponse: + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def read(self): + return b"ok" + + return DummyResponse() + + monkeypatch.setattr(mesh.urllib.request, "urlopen", fake_urlopen) + + # Explicitly pass empty string to skip version header + mesh._post_json("/api/test", {"data": "test"}, ingestor_version="") + + req = captured["req"] + # X-Ingestor-Version header should not be present when version is empty + assert req.get_header("X-ingestor-version") is None + + def test_node_to_dict_handles_non_utf8_bytes(mesh_module): mesh = mesh_module diff --git a/web/lib/potato_mesh/application.rb b/web/lib/potato_mesh/application.rb index 257352d5..63ca00f1 100644 --- a/web/lib/potato_mesh/application.rb +++ b/web/lib/potato_mesh/application.rb @@ -52,8 +52,10 @@ require_relative "application/data_processing" require_relative "application/filesystem" require_relative "application/instances" +require_relative "application/ingestors" require_relative "application/routes/api" require_relative "application/routes/ingest" +require_relative "application/routes/admin" require_relative "application/routes/root" module PotatoMesh @@ -64,6 +66,7 @@ class Application < Sinatra::Base extend App::Identity extend App::Federation extend App::Instances + extend App::Ingestors extend App::Prometheus extend App::Queries extend App::DataProcessing @@ -75,6 +78,7 @@ class Application < Sinatra::Base include App::Identity include App::Federation include App::Instances + include App::Ingestors include App::Prometheus include App::Queries include App::DataProcessing @@ -82,6 +86,7 @@ class Application < Sinatra::Base register App::Routes::Api register App::Routes::Ingest + register App::Routes::Admin register App::Routes::Root DEFAULT_PORT = 41_447 diff --git a/web/lib/potato_mesh/application/data_processing.rb b/web/lib/potato_mesh/application/data_processing.rb index 5d796d1a..2849e308 100644 --- a/web/lib/potato_mesh/application/data_processing.rb +++ b/web/lib/potato_mesh/application/data_processing.rb @@ -278,10 +278,37 @@ def upsert_node(db, node_id, n) end end + # Validate the request's authorization token. + # + # Accepts both the shared API_TOKEN and individual ingestor API keys. + # When an ingestor key is used, the request is recorded for tracking. + # + # @return [void] def require_token! token = ENV["API_TOKEN"] provided = request.env["HTTP_AUTHORIZATION"].to_s.sub(/^Bearer\s+/i, "") - halt 403, { error: "Forbidden" }.to_json unless token && !token.empty? && secure_token_match?(token, provided) + + # First check against the shared API_TOKEN + if token && !token.empty? && secure_token_match?(token, provided) + return + end + + # Then check against registered ingestor API keys + if PotatoMesh::Config.ingestor_management_enabled? && !provided.empty? + db = open_database + begin + ingestor = validate_ingestor_token(db, provided) + if ingestor + version = request.env["HTTP_X_INGESTOR_VERSION"] + record_ingestor_request(db, provided, version: version) + return + end + ensure + db&.close + end + end + + halt 403, { error: "Forbidden" }.to_json end def secure_token_match?(expected, provided) diff --git a/web/lib/potato_mesh/application/database.rb b/web/lib/potato_mesh/application/database.rb index 7bee1e39..96c55cf3 100644 --- a/web/lib/potato_mesh/application/database.rb +++ b/web/lib/potato_mesh/application/database.rb @@ -99,7 +99,7 @@ def db_schema_present? def init_db FileUtils.mkdir_p(File.dirname(PotatoMesh::Config.db_path)) db = open_database - %w[nodes messages positions telemetry neighbors instances traces].each do |schema| + %w[nodes messages positions telemetry neighbors instances traces ingestors].each do |schema| sql_file = File.expand_path("../../../../data/#{schema}.sql", __dir__) db.execute_batch(File.read(sql_file)) end @@ -192,6 +192,13 @@ def ensure_schema_upgrades traces_schema = File.expand_path("../../../../data/traces.sql", __dir__) db.execute_batch(File.read(traces_schema)) end + + ingestor_tables = + db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='ingestors'").flatten + if ingestor_tables.empty? + ingestors_schema = File.expand_path("../../../../data/ingestors.sql", __dir__) + db.execute_batch(File.read(ingestors_schema)) + end rescue SQLite3::SQLException, Errno::ENOENT => e warn_log( "Failed to apply schema upgrade", diff --git a/web/lib/potato_mesh/application/helpers.rb b/web/lib/potato_mesh/application/helpers.rb index 837fc45e..55768937 100644 --- a/web/lib/potato_mesh/application/helpers.rb +++ b/web/lib/potato_mesh/application/helpers.rb @@ -421,6 +421,31 @@ def federation_enabled? def federation_announcements_active? federation_enabled? && !test_environment? end + + # Validate the admin token from the Authorization header. + # + # @return [void] + def require_admin_token! + admin_token = PotatoMesh::Config.admin_token + provided = request.env["HTTP_AUTHORIZATION"].to_s.sub(/^Bearer\s+/i, "") + + unless admin_token && !admin_token.empty? && secure_token_match?(admin_token, provided) + halt 403, { error: "Forbidden" }.to_json + end + end + + # Mask an API key for display, showing only the first and last segments. + # + # @param api_key [String, nil] the full API key. + # @return [String, nil] masked key or nil. + def mask_api_key(api_key) + return nil if api_key.nil? || api_key.empty? + + parts = api_key.split("-") + return "****" if parts.length < 2 + + "#{parts.first}-****-****-****-#{parts.last}" + end end end end diff --git a/web/lib/potato_mesh/application/ingestors.rb b/web/lib/potato_mesh/application/ingestors.rb new file mode 100644 index 00000000..da2cce6c --- /dev/null +++ b/web/lib/potato_mesh/application/ingestors.rb @@ -0,0 +1,352 @@ +# Copyright © 2025-26 l5yth, apo-mak & 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 "securerandom" + +module PotatoMesh + module App + # Helper methods for managing ingestor registrations and API keys. + # + # Ingestors are external data collectors that feed mesh data into the + # PotatoMesh instance. Each ingestor receives a unique API key that + # can be used for authentication instead of the shared API_TOKEN. + module Ingestors + # Generate a new secure API key for ingestor authentication. + # + # @return [String] a UUID-format API key. + def generate_ingestor_api_key + SecureRandom.uuid + end + + # Generate a unique ingestor identifier. + # + # @return [String] a UUID-format identifier. + def generate_ingestor_id + SecureRandom.uuid + end + + # Create a new ingestor registration in the database. + # + # @param db [SQLite3::Database] database connection. + # @param name [String, nil] friendly name for the ingestor. + # @param node_id [String, nil] associated mesh node identifier. + # @param contact_email [String, nil] contact email address. + # @param contact_matrix [String, nil] Matrix username for contact. + # @return [Hash] the created ingestor record with api_key. + def create_ingestor(db, name: nil, node_id: nil, contact_email: nil, contact_matrix: nil) + id = generate_ingestor_id + api_key = generate_ingestor_api_key + created_at = Time.now.to_i + + with_busy_retry do + db.execute( + <<~SQL, + INSERT INTO ingestors (id, api_key, name, node_id, contact_email, contact_matrix, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + SQL + [id, api_key, name, node_id, contact_email, contact_matrix, created_at], + ) + end + + { + "id" => id, + "api_key" => api_key, + "name" => name, + "node_id" => node_id, + "contact_email" => contact_email, + "contact_matrix" => contact_matrix, + "version" => nil, + "last_request_time" => nil, + "request_count" => 0, + "created_at" => created_at, + "is_active" => true, + } + end + + # Find an ingestor by its API key. + # + # @param db [SQLite3::Database] database connection. + # @param api_key [String] the API key to look up. + # @return [Hash, nil] the ingestor record or nil if not found. + def find_ingestor_by_api_key(db, api_key) + return nil if api_key.nil? || api_key.empty? + + row = with_busy_retry do + db.execute( + <<~SQL, + SELECT id, api_key, name, node_id, contact_email, contact_matrix, + version, last_request_time, request_count, created_at, is_active + FROM ingestors + WHERE api_key = ? AND is_active = 1 + SQL + [api_key], + ).first + end + + return nil unless row + + { + "id" => row[0], + "api_key" => row[1], + "name" => row[2], + "node_id" => row[3], + "contact_email" => row[4], + "contact_matrix" => row[5], + "version" => row[6], + "last_request_time" => row[7], + "request_count" => row[8], + "created_at" => row[9], + "is_active" => row[10] == 1, + } + end + + # Find an ingestor by its identifier. + # + # @param db [SQLite3::Database] database connection. + # @param id [String] the ingestor identifier. + # @return [Hash, nil] the ingestor record or nil if not found. + def find_ingestor_by_id(db, id) + return nil if id.nil? || id.empty? + + row = with_busy_retry do + db.execute( + <<~SQL, + SELECT id, api_key, name, node_id, contact_email, contact_matrix, + version, last_request_time, request_count, created_at, is_active + FROM ingestors + WHERE id = ? + SQL + [id], + ).first + end + + return nil unless row + + { + "id" => row[0], + "api_key" => row[1], + "name" => row[2], + "node_id" => row[3], + "contact_email" => row[4], + "contact_matrix" => row[5], + "version" => row[6], + "last_request_time" => row[7], + "request_count" => row[8], + "created_at" => row[9], + "is_active" => row[10] == 1, + } + end + + # List all registered ingestors. + # + # @param db [SQLite3::Database] database connection. + # @param include_inactive [Boolean] whether to include deactivated ingestors. + # @return [Array] list of ingestor records. + def list_ingestors(db, include_inactive: false) + query = <<~SQL + SELECT id, api_key, name, node_id, contact_email, contact_matrix, + version, last_request_time, request_count, created_at, is_active + FROM ingestors + #{include_inactive ? "" : "WHERE is_active = 1"} + ORDER BY created_at DESC + SQL + + rows = with_busy_retry { db.execute(query) } + + rows.map do |row| + { + "id" => row[0], + "api_key" => row[1], + "name" => row[2], + "node_id" => row[3], + "contact_email" => row[4], + "contact_matrix" => row[5], + "version" => row[6], + "last_request_time" => row[7], + "request_count" => row[8], + "created_at" => row[9], + "is_active" => row[10] == 1, + } + end + end + + # Update the last request timestamp and version for an ingestor. + # + # @param db [SQLite3::Database] database connection. + # @param api_key [String] the ingestor's API key. + # @param version [String, nil] the ingestor version reported in headers. + # @return [void] + def record_ingestor_request(db, api_key, version: nil) + return if api_key.nil? || api_key.empty? + + now = Time.now.to_i + + with_busy_retry do + if version + db.execute( + <<~SQL, + UPDATE ingestors + SET last_request_time = ?, request_count = request_count + 1, version = ? + WHERE api_key = ? + SQL + [now, version, api_key], + ) + else + db.execute( + <<~SQL, + UPDATE ingestors + SET last_request_time = ?, request_count = request_count + 1 + WHERE api_key = ? + SQL + [now, api_key], + ) + end + end + end + + # Update an ingestor's registration details. + # + # @param db [SQLite3::Database] database connection. + # @param id [String] the ingestor identifier. + # @param name [String, nil] new friendly name. + # @param node_id [String, nil] new associated node identifier. + # @param contact_email [String, nil] new contact email. + # @param contact_matrix [String, nil] new Matrix username. + # @return [Boolean] true if the update succeeded. + def update_ingestor(db, id, name: nil, node_id: nil, contact_email: nil, contact_matrix: nil) + return false if id.nil? || id.empty? + + updates = [] + params = [] + + unless name.nil? + updates << "name = ?" + params << name + end + + unless node_id.nil? + updates << "node_id = ?" + params << node_id + end + + unless contact_email.nil? + updates << "contact_email = ?" + params << contact_email + end + + unless contact_matrix.nil? + updates << "contact_matrix = ?" + params << contact_matrix + end + + return false if updates.empty? + + params << id + + with_busy_retry do + db.execute( + "UPDATE ingestors SET #{updates.join(", ")} WHERE id = ?", + params, + ) + end + + db.changes.positive? + end + + # Regenerate the API key for an ingestor. + # + # @param db [SQLite3::Database] database connection. + # @param id [String] the ingestor identifier. + # @return [String, nil] the new API key or nil if ingestor not found. + def regenerate_ingestor_api_key(db, id) + return nil if id.nil? || id.empty? + + new_key = generate_ingestor_api_key + + with_busy_retry do + db.execute( + "UPDATE ingestors SET api_key = ? WHERE id = ?", + [new_key, id], + ) + end + + db.changes.positive? ? new_key : nil + end + + # Deactivate an ingestor (soft delete). + # + # @param db [SQLite3::Database] database connection. + # @param id [String] the ingestor identifier. + # @return [Boolean] true if the deactivation succeeded. + def deactivate_ingestor(db, id) + return false if id.nil? || id.empty? + + with_busy_retry do + db.execute( + "UPDATE ingestors SET is_active = 0 WHERE id = ?", + [id], + ) + end + + db.changes.positive? + end + + # Reactivate a previously deactivated ingestor. + # + # @param db [SQLite3::Database] database connection. + # @param id [String] the ingestor identifier. + # @return [Boolean] true if the reactivation succeeded. + def reactivate_ingestor(db, id) + return false if id.nil? || id.empty? + + with_busy_retry do + db.execute( + "UPDATE ingestors SET is_active = 1 WHERE id = ?", + [id], + ) + end + + db.changes.positive? + end + + # Permanently delete an ingestor from the database. + # + # @param db [SQLite3::Database] database connection. + # @param id [String] the ingestor identifier. + # @return [Boolean] true if the deletion succeeded. + def delete_ingestor(db, id) + return false if id.nil? || id.empty? + + with_busy_retry do + db.execute("DELETE FROM ingestors WHERE id = ?", [id]) + end + + db.changes.positive? + end + + # Check if the provided token matches any active ingestor API key. + # + # @param db [SQLite3::Database] database connection. + # @param token [String] the token to validate. + # @return [Hash, nil] the ingestor record if valid, nil otherwise. + def validate_ingestor_token(db, token) + return nil if token.nil? || token.empty? + + find_ingestor_by_api_key(db, token) + end + end + end +end diff --git a/web/lib/potato_mesh/application/routes/admin.rb b/web/lib/potato_mesh/application/routes/admin.rb new file mode 100644 index 00000000..6edc0c53 --- /dev/null +++ b/web/lib/potato_mesh/application/routes/admin.rb @@ -0,0 +1,275 @@ +# Copyright © 2025-26 l5yth, apo-mak & 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 + module Routes + # Admin routes for managing ingestor registrations. + # + # These endpoints require the ADMIN_TOKEN for authentication and are + # only available when INGESTOR_MANAGEMENT=1 is set. + module Admin + # Register admin endpoints for ingestor management. + # + # @param app [Sinatra::Base] application instance receiving the routes. + # @return [void] + def self.registered(app) + # Guard all admin routes to check if ingestor management is enabled + app.before "/admin/*" do + unless PotatoMesh::Config.ingestor_management_enabled? + halt 404, { error: "not found" }.to_json + end + end + + # Validate admin token for all admin endpoints + app.before "/admin/*" do + require_admin_token! + end + + # List all registered ingestors + app.get "/admin/ingestors" do + content_type :json + include_inactive = params["include_inactive"] == "1" + db = open_database(readonly: true) + begin + ingestors = list_ingestors(db, include_inactive: include_inactive) + # Mask API keys in list view for security + masked = ingestors.map do |ing| + ing.merge("api_key" => mask_api_key(ing["api_key"])) + end + { ingestors: masked }.to_json + ensure + db&.close + end + end + + # Get a single ingestor by ID + app.get "/admin/ingestors/:id" do + content_type :json + id = string_or_nil(params["id"]) + halt 400, { error: "missing ingestor id" }.to_json unless id + + db = open_database(readonly: true) + begin + ingestor = find_ingestor_by_id(db, id) + halt 404, { error: "ingestor not found" }.to_json unless ingestor + # Mask API key for security + ingestor["api_key"] = mask_api_key(ingestor["api_key"]) + ingestor.to_json + ensure + db&.close + end + end + + # Create a new ingestor registration + app.post "/admin/ingestors" do + content_type :json + begin + data = JSON.parse(read_json_body) + rescue JSON::ParserError + halt 400, { error: "invalid JSON" }.to_json + end + + name = string_or_nil(data["name"]) + node_id = string_or_nil(data["node_id"]) + contact_email = string_or_nil(data["contact_email"]) + contact_matrix = string_or_nil(data["contact_matrix"]) + + db = open_database + begin + ingestor = create_ingestor( + db, + name: name, + node_id: node_id, + contact_email: contact_email, + contact_matrix: contact_matrix, + ) + + warn_log( + "Created new ingestor", + context: "admin.ingestors.create", + ingestor_id: ingestor["id"], + name: name, + node_id: node_id, + ) + + # Return full API key only on creation + status 201 + ingestor.to_json + ensure + db&.close + end + end + + # Update an existing ingestor + app.patch "/admin/ingestors/:id" do + content_type :json + id = string_or_nil(params["id"]) + halt 400, { error: "missing ingestor id" }.to_json unless id + + begin + data = JSON.parse(read_json_body) + rescue JSON::ParserError + halt 400, { error: "invalid JSON" }.to_json + end + + db = open_database + begin + existing = find_ingestor_by_id(db, id) + halt 404, { error: "ingestor not found" }.to_json unless existing + + # Build update parameters - only include fields that are present + update_params = {} + update_params[:name] = string_or_nil(data["name"]) if data.key?("name") + update_params[:node_id] = string_or_nil(data["node_id"]) if data.key?("node_id") + update_params[:contact_email] = string_or_nil(data["contact_email"]) if data.key?("contact_email") + update_params[:contact_matrix] = string_or_nil(data["contact_matrix"]) if data.key?("contact_matrix") + + if update_params.empty? + halt 400, { error: "no fields to update" }.to_json + end + + success = update_ingestor(db, id, **update_params) + halt 500, { error: "update failed" }.to_json unless success + + warn_log( + "Updated ingestor", + context: "admin.ingestors.update", + ingestor_id: id, + updated_fields: update_params.keys.join(","), + ) + + updated = find_ingestor_by_id(db, id) + updated["api_key"] = mask_api_key(updated["api_key"]) + updated.to_json + ensure + db&.close + end + end + + # Regenerate API key for an ingestor + app.post "/admin/ingestors/:id/regenerate-key" do + content_type :json + id = string_or_nil(params["id"]) + halt 400, { error: "missing ingestor id" }.to_json unless id + + db = open_database + begin + existing = find_ingestor_by_id(db, id) + halt 404, { error: "ingestor not found" }.to_json unless existing + + new_key = regenerate_ingestor_api_key(db, id) + halt 500, { error: "key regeneration failed" }.to_json unless new_key + + warn_log( + "Regenerated ingestor API key", + context: "admin.ingestors.regenerate_key", + ingestor_id: id, + name: existing["name"], + ) + + # Return the new key - this is the only time it will be visible + { id: id, api_key: new_key }.to_json + ensure + db&.close + end + end + + # Deactivate an ingestor (soft delete) + app.post "/admin/ingestors/:id/deactivate" do + content_type :json + id = string_or_nil(params["id"]) + halt 400, { error: "missing ingestor id" }.to_json unless id + + db = open_database + begin + existing = find_ingestor_by_id(db, id) + halt 404, { error: "ingestor not found" }.to_json unless existing + + success = deactivate_ingestor(db, id) + halt 500, { error: "deactivation failed" }.to_json unless success + + warn_log( + "Deactivated ingestor", + context: "admin.ingestors.deactivate", + ingestor_id: id, + name: existing["name"], + ) + + { status: "deactivated", id: id }.to_json + ensure + db&.close + end + end + + # Reactivate an ingestor + app.post "/admin/ingestors/:id/reactivate" do + content_type :json + id = string_or_nil(params["id"]) + halt 400, { error: "missing ingestor id" }.to_json unless id + + db = open_database + begin + existing = find_ingestor_by_id(db, id) + halt 404, { error: "ingestor not found" }.to_json unless existing + + success = reactivate_ingestor(db, id) + halt 500, { error: "reactivation failed" }.to_json unless success + + warn_log( + "Reactivated ingestor", + context: "admin.ingestors.reactivate", + ingestor_id: id, + name: existing["name"], + ) + + { status: "reactivated", id: id }.to_json + ensure + db&.close + end + end + + # Permanently delete an ingestor + app.delete "/admin/ingestors/:id" do + content_type :json + id = string_or_nil(params["id"]) + halt 400, { error: "missing ingestor id" }.to_json unless id + + db = open_database + begin + existing = find_ingestor_by_id(db, id) + halt 404, { error: "ingestor not found" }.to_json unless existing + + success = delete_ingestor(db, id) + halt 500, { error: "deletion failed" }.to_json unless success + + warn_log( + "Deleted ingestor", + context: "admin.ingestors.delete", + ingestor_id: id, + name: existing["name"], + ) + + { status: "deleted", id: id }.to_json + ensure + db&.close + end + end + end + end + end + end +end diff --git a/web/lib/potato_mesh/config.rb b/web/lib/potato_mesh/config.rb index 3e1834e4..30bd12da 100644 --- a/web/lib/potato_mesh/config.rb +++ b/web/lib/potato_mesh/config.rb @@ -79,6 +79,28 @@ def federation_enabled? value.to_s.strip != "0" end + # Determine whether ingestor management features are enabled. + # + # When enabled, the instance can register and manage external ingestors + # with individual API keys instead of relying solely on the shared + # API_TOKEN. + # + # @return [Boolean] true when INGESTOR_MANAGEMENT=1 in the environment. + def ingestor_management_enabled? + value = ENV.fetch("INGESTOR_MANAGEMENT", "0") + value.to_s.strip == "1" + end + + # Retrieve the admin token used to manage ingestors. + # + # This token is separate from API_TOKEN and grants access to ingestor + # management endpoints when ingestor management is enabled. + # + # @return [String, nil] admin token when provided, otherwise nil. + def admin_token + fetch_string("ADMIN_TOKEN", nil) + end + # Resolve the absolute path to the web application root directory. # # @return [String] absolute filesystem path of the web folder. diff --git a/web/spec/ingestors_spec.rb b/web/spec/ingestors_spec.rb new file mode 100644 index 00000000..8b56545d --- /dev/null +++ b/web/spec/ingestors_spec.rb @@ -0,0 +1,1147 @@ +# Copyright © 2025-26 l5yth, apo-mak & 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" + +RSpec.describe PotatoMesh::App::Ingestors do + let(:harness_class) do + Class.new do + extend PotatoMesh::App::Database + extend PotatoMesh::App::Ingestors + extend PotatoMesh::App::Helpers + end + end + + around do |example| + Dir.mktmpdir("ingestors-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(:default_db_path).and_return(db_path) + allow(PotatoMesh::Config).to receive(:legacy_db_path).and_return(db_path) + + FileUtils.mkdir_p(File.dirname(db_path)) + harness_class.init_db + + example.run + end + end + end + + def open_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") + db + end + + describe ".generate_ingestor_api_key" do + it "generates a UUID format string" do + key = harness_class.generate_ingestor_api_key + expect(key).to match(/\A[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\z/) + end + + it "generates unique keys" do + keys = 10.times.map { harness_class.generate_ingestor_api_key } + expect(keys.uniq.size).to eq(10) + end + end + + describe ".generate_ingestor_id" do + it "generates a UUID format string" do + id = harness_class.generate_ingestor_id + expect(id).to match(/\A[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\z/) + end + end + + describe ".create_ingestor" do + it "creates an ingestor with all provided fields" do + db = open_db + begin + ingestor = harness_class.create_ingestor( + db, + name: "Test Ingestor", + node_id: "!abc123", + contact_email: "test@example.com", + contact_matrix: "@test:matrix.org", + ) + + expect(ingestor["id"]).to be_a(String) + expect(ingestor["api_key"]).to be_a(String) + expect(ingestor["name"]).to eq("Test Ingestor") + expect(ingestor["node_id"]).to eq("!abc123") + expect(ingestor["contact_email"]).to eq("test@example.com") + expect(ingestor["contact_matrix"]).to eq("@test:matrix.org") + expect(ingestor["is_active"]).to be(true) + expect(ingestor["created_at"]).to be_a(Integer) + expect(ingestor["request_count"]).to eq(0) + ensure + db&.close + end + end + + it "creates an ingestor with minimal fields" do + db = open_db + begin + ingestor = harness_class.create_ingestor(db) + + expect(ingestor["id"]).to be_a(String) + expect(ingestor["api_key"]).to be_a(String) + expect(ingestor["name"]).to be_nil + expect(ingestor["node_id"]).to be_nil + expect(ingestor["is_active"]).to be(true) + ensure + db&.close + end + end + + it "persists the ingestor to the database" do + db = open_db + begin + ingestor = harness_class.create_ingestor(db, name: "Persisted") + row = db.execute("SELECT name FROM ingestors WHERE id = ?", [ingestor["id"]]).first + + expect(row[0]).to eq("Persisted") + ensure + db&.close + end + end + end + + describe ".find_ingestor_by_api_key" do + it "returns the ingestor when found" do + db = open_db + begin + created = harness_class.create_ingestor(db, name: "Find Test") + found = harness_class.find_ingestor_by_api_key(db, created["api_key"]) + + expect(found["id"]).to eq(created["id"]) + expect(found["name"]).to eq("Find Test") + ensure + db&.close + end + end + + it "returns nil for inactive ingestors" do + db = open_db + begin + created = harness_class.create_ingestor(db, name: "Inactive Test") + harness_class.deactivate_ingestor(db, created["id"]) + found = harness_class.find_ingestor_by_api_key(db, created["api_key"]) + + expect(found).to be_nil + ensure + db&.close + end + end + + it "returns nil for unknown keys" do + db = open_db + begin + found = harness_class.find_ingestor_by_api_key(db, "unknown-key") + expect(found).to be_nil + ensure + db&.close + end + end + + it "returns nil for nil or empty keys" do + db = open_db + begin + expect(harness_class.find_ingestor_by_api_key(db, nil)).to be_nil + expect(harness_class.find_ingestor_by_api_key(db, "")).to be_nil + ensure + db&.close + end + end + end + + describe ".find_ingestor_by_id" do + it "returns the ingestor when found" do + db = open_db + begin + created = harness_class.create_ingestor(db, name: "Find By ID") + found = harness_class.find_ingestor_by_id(db, created["id"]) + + expect(found["api_key"]).to eq(created["api_key"]) + expect(found["name"]).to eq("Find By ID") + ensure + db&.close + end + end + + it "returns inactive ingestors" do + db = open_db + begin + created = harness_class.create_ingestor(db, name: "Inactive") + harness_class.deactivate_ingestor(db, created["id"]) + found = harness_class.find_ingestor_by_id(db, created["id"]) + + expect(found).not_to be_nil + expect(found["is_active"]).to be(false) + ensure + db&.close + end + end + + it "returns nil for unknown IDs" do + db = open_db + begin + found = harness_class.find_ingestor_by_id(db, "unknown-id") + expect(found).to be_nil + ensure + db&.close + end + end + + it "returns nil for nil or empty ID" do + db = open_db + begin + expect(harness_class.find_ingestor_by_id(db, nil)).to be_nil + expect(harness_class.find_ingestor_by_id(db, "")).to be_nil + ensure + db&.close + end + end + end + + describe ".list_ingestors" do + it "returns all active ingestors" do + db = open_db + begin + harness_class.create_ingestor(db, name: "Active 1") + harness_class.create_ingestor(db, name: "Active 2") + inactive = harness_class.create_ingestor(db, name: "Inactive") + harness_class.deactivate_ingestor(db, inactive["id"]) + + list = harness_class.list_ingestors(db) + + expect(list.size).to eq(2) + expect(list.map { |i| i["name"] }).to contain_exactly("Active 1", "Active 2") + ensure + db&.close + end + end + + it "includes inactive when requested" do + db = open_db + begin + harness_class.create_ingestor(db, name: "Active") + inactive = harness_class.create_ingestor(db, name: "Inactive") + harness_class.deactivate_ingestor(db, inactive["id"]) + + list = harness_class.list_ingestors(db, include_inactive: true) + + expect(list.size).to eq(2) + ensure + db&.close + end + end + + it "returns ingestors ordered by created_at DESC" do + db = open_db + begin + # Create first ingestor with an older timestamp + first = harness_class.create_ingestor(db, name: "First") + # Manually update created_at to ensure ordering (since integer seconds) + db.execute( + "UPDATE ingestors SET created_at = ? WHERE id = ?", + [Time.now.to_i - 10, first["id"]], + ) + second = harness_class.create_ingestor(db, name: "Second") + + list = harness_class.list_ingestors(db) + + expect(list.first["name"]).to eq("Second") + expect(list.last["name"]).to eq("First") + ensure + db&.close + end + end + end + + describe ".record_ingestor_request" do + it "updates last_request_time and increments request_count" do + db = open_db + begin + created = harness_class.create_ingestor(db, name: "Request Test") + + before_time = Time.now.to_i + harness_class.record_ingestor_request(db, created["api_key"]) + after_time = Time.now.to_i + + updated = harness_class.find_ingestor_by_id(db, created["id"]) + + expect(updated["last_request_time"]).to be >= before_time + expect(updated["last_request_time"]).to be <= after_time + expect(updated["request_count"]).to eq(1) + ensure + db&.close + end + end + + it "updates version when provided" do + db = open_db + begin + created = harness_class.create_ingestor(db, name: "Version Test") + harness_class.record_ingestor_request(db, created["api_key"], version: "1.2.3") + + updated = harness_class.find_ingestor_by_id(db, created["id"]) + + expect(updated["version"]).to eq("1.2.3") + ensure + db&.close + end + end + + it "does nothing for nil or empty keys" do + db = open_db + begin + expect { harness_class.record_ingestor_request(db, nil) }.not_to raise_error + expect { harness_class.record_ingestor_request(db, "") }.not_to raise_error + ensure + db&.close + end + end + end + + describe ".update_ingestor" do + it "updates specified fields" do + db = open_db + begin + created = harness_class.create_ingestor(db, name: "Original") + success = harness_class.update_ingestor( + db, + created["id"], + name: "Updated", + node_id: "!newnode", + ) + + expect(success).to be(true) + + updated = harness_class.find_ingestor_by_id(db, created["id"]) + expect(updated["name"]).to eq("Updated") + expect(updated["node_id"]).to eq("!newnode") + ensure + db&.close + end + end + + it "returns false when no fields provided" do + db = open_db + begin + created = harness_class.create_ingestor(db, name: "Test") + success = harness_class.update_ingestor(db, created["id"]) + + expect(success).to be(false) + ensure + db&.close + end + end + + it "returns false for unknown IDs" do + db = open_db + begin + success = harness_class.update_ingestor(db, "unknown", name: "Test") + expect(success).to be(false) + ensure + db&.close + end + end + + it "returns false for nil or empty ID" do + db = open_db + begin + expect(harness_class.update_ingestor(db, nil, name: "Test")).to be(false) + expect(harness_class.update_ingestor(db, "", name: "Test")).to be(false) + ensure + db&.close + end + end + end + + describe ".regenerate_ingestor_api_key" do + it "generates a new API key" do + db = open_db + begin + created = harness_class.create_ingestor(db, name: "Regen Test") + original_key = created["api_key"] + + new_key = harness_class.regenerate_ingestor_api_key(db, created["id"]) + + expect(new_key).not_to eq(original_key) + expect(new_key).to match(/\A[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\z/) + + # Verify old key no longer works + found_with_old = harness_class.find_ingestor_by_api_key(db, original_key) + expect(found_with_old).to be_nil + + # Verify new key works + found_with_new = harness_class.find_ingestor_by_api_key(db, new_key) + expect(found_with_new["id"]).to eq(created["id"]) + ensure + db&.close + end + end + + it "returns nil for unknown IDs" do + db = open_db + begin + result = harness_class.regenerate_ingestor_api_key(db, "unknown") + expect(result).to be_nil + ensure + db&.close + end + end + + it "returns nil for nil or empty ID" do + db = open_db + begin + expect(harness_class.regenerate_ingestor_api_key(db, nil)).to be_nil + expect(harness_class.regenerate_ingestor_api_key(db, "")).to be_nil + ensure + db&.close + end + end + end + + describe ".deactivate_ingestor" do + it "sets is_active to false" do + db = open_db + begin + created = harness_class.create_ingestor(db, name: "Deactivate Test") + success = harness_class.deactivate_ingestor(db, created["id"]) + + expect(success).to be(true) + + updated = harness_class.find_ingestor_by_id(db, created["id"]) + expect(updated["is_active"]).to be(false) + ensure + db&.close + end + end + + it "returns false for unknown IDs" do + db = open_db + begin + success = harness_class.deactivate_ingestor(db, "unknown") + expect(success).to be(false) + ensure + db&.close + end + end + + it "returns false for nil or empty ID" do + db = open_db + begin + expect(harness_class.deactivate_ingestor(db, nil)).to be(false) + expect(harness_class.deactivate_ingestor(db, "")).to be(false) + ensure + db&.close + end + end + end + + describe ".reactivate_ingestor" do + it "sets is_active to true" do + db = open_db + begin + created = harness_class.create_ingestor(db, name: "Reactivate Test") + harness_class.deactivate_ingestor(db, created["id"]) + success = harness_class.reactivate_ingestor(db, created["id"]) + + expect(success).to be(true) + + updated = harness_class.find_ingestor_by_id(db, created["id"]) + expect(updated["is_active"]).to be(true) + ensure + db&.close + end + end + + it "returns false for unknown IDs" do + db = open_db + begin + success = harness_class.reactivate_ingestor(db, "unknown") + expect(success).to be(false) + ensure + db&.close + end + end + + it "returns false for nil or empty ID" do + db = open_db + begin + expect(harness_class.reactivate_ingestor(db, nil)).to be(false) + expect(harness_class.reactivate_ingestor(db, "")).to be(false) + ensure + db&.close + end + end + end + + describe ".delete_ingestor" do + it "permanently removes the ingestor" do + db = open_db + begin + created = harness_class.create_ingestor(db, name: "Delete Test") + success = harness_class.delete_ingestor(db, created["id"]) + + expect(success).to be(true) + + found = harness_class.find_ingestor_by_id(db, created["id"]) + expect(found).to be_nil + ensure + db&.close + end + end + + it "returns false for unknown IDs" do + db = open_db + begin + success = harness_class.delete_ingestor(db, "unknown") + expect(success).to be(false) + ensure + db&.close + end + end + + it "returns false for nil or empty ID" do + db = open_db + begin + expect(harness_class.delete_ingestor(db, nil)).to be(false) + expect(harness_class.delete_ingestor(db, "")).to be(false) + ensure + db&.close + end + end + end + + describe ".validate_ingestor_token" do + it "returns ingestor for valid active token" do + db = open_db + begin + created = harness_class.create_ingestor(db, name: "Validate Test") + result = harness_class.validate_ingestor_token(db, created["api_key"]) + + expect(result).not_to be_nil + expect(result["id"]).to eq(created["id"]) + ensure + db&.close + end + end + + it "returns nil for inactive ingestor" do + db = open_db + begin + created = harness_class.create_ingestor(db, name: "Inactive") + harness_class.deactivate_ingestor(db, created["id"]) + result = harness_class.validate_ingestor_token(db, created["api_key"]) + + expect(result).to be_nil + ensure + db&.close + end + end + + it "returns nil for invalid tokens" do + db = open_db + begin + result = harness_class.validate_ingestor_token(db, "invalid-token") + expect(result).to be_nil + ensure + db&.close + end + end + + it "returns nil for nil or empty token" do + db = open_db + begin + expect(harness_class.validate_ingestor_token(db, nil)).to be_nil + expect(harness_class.validate_ingestor_token(db, "")).to be_nil + ensure + db&.close + end + end + end +end + +RSpec.describe PotatoMesh::App::Helpers do + describe ".mask_api_key" do + let(:helper_class) do + Class.new do + extend PotatoMesh::App::Helpers + end + end + + it "masks the middle segments of a UUID key" do + masked = helper_class.mask_api_key("a1b2c3d4-e5f6-7890-abcd-ef1234567890") + expect(masked).to eq("a1b2c3d4-****-****-****-ef1234567890") + end + + it "returns nil for nil input" do + expect(helper_class.mask_api_key(nil)).to be_nil + end + + it "returns nil for empty string" do + expect(helper_class.mask_api_key("")).to be_nil + end + + it "returns **** for keys without hyphens" do + expect(helper_class.mask_api_key("simplekey")).to eq("****") + end + end +end + +RSpec.describe PotatoMesh::Config do + describe ".ingestor_management_enabled?" do + around do |example| + original = ENV["INGESTOR_MANAGEMENT"] + begin + example.run + ensure + if original + ENV["INGESTOR_MANAGEMENT"] = original + else + ENV.delete("INGESTOR_MANAGEMENT") + end + end + end + + it "returns false by default" do + ENV.delete("INGESTOR_MANAGEMENT") + expect(PotatoMesh::Config.ingestor_management_enabled?).to be(false) + end + + it "returns true when set to 1" do + ENV["INGESTOR_MANAGEMENT"] = "1" + expect(PotatoMesh::Config.ingestor_management_enabled?).to be(true) + end + + it "returns false for other values" do + ENV["INGESTOR_MANAGEMENT"] = "0" + expect(PotatoMesh::Config.ingestor_management_enabled?).to be(false) + + ENV["INGESTOR_MANAGEMENT"] = "true" + expect(PotatoMesh::Config.ingestor_management_enabled?).to be(false) + + ENV["INGESTOR_MANAGEMENT"] = "yes" + expect(PotatoMesh::Config.ingestor_management_enabled?).to be(false) + end + end + + describe ".admin_token" do + around do |example| + original = ENV["ADMIN_TOKEN"] + begin + example.run + ensure + if original + ENV["ADMIN_TOKEN"] = original + else + ENV.delete("ADMIN_TOKEN") + end + end + end + + it "returns nil when not set" do + ENV.delete("ADMIN_TOKEN") + expect(PotatoMesh::Config.admin_token).to be_nil + end + + it "returns the token when set" do + ENV["ADMIN_TOKEN"] = "my-secret-token" + expect(PotatoMesh::Config.admin_token).to eq("my-secret-token") + end + + it "returns nil for empty string" do + ENV["ADMIN_TOKEN"] = "" + expect(PotatoMesh::Config.admin_token).to be_nil + end + end +end + +RSpec.describe "Admin Ingestor Routes" do + include Rack::Test::Methods + + let(:app) { Sinatra::Application } + let(:admin_token) { "test-admin-token" } + let(:admin_headers) do + { + "CONTENT_TYPE" => "application/json", + "HTTP_AUTHORIZATION" => "Bearer #{admin_token}", + } + end + + around do |example| + Dir.mktmpdir("admin-routes-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(:default_db_path).and_return(db_path) + allow(PotatoMesh::Config).to receive(:legacy_db_path).and_return(db_path) + allow(PotatoMesh::Config).to receive(:ingestor_management_enabled?).and_return(true) + allow(PotatoMesh::Config).to receive(:admin_token).and_return(admin_token) + + FileUtils.mkdir_p(File.dirname(db_path)) + PotatoMesh::Application.init_db + + example.run + end + end + end + + def open_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") + db + end + + describe "GET /admin/ingestors" do + it "returns 404 when ingestor management is disabled" do + allow(PotatoMesh::Config).to receive(:ingestor_management_enabled?).and_return(false) + get "/admin/ingestors", {}, admin_headers + expect(last_response.status).to eq(404) + end + + it "returns 403 without admin token" do + get "/admin/ingestors", {}, { "CONTENT_TYPE" => "application/json" } + expect(last_response.status).to eq(403) + end + + it "returns 403 with invalid admin token" do + headers = admin_headers.merge("HTTP_AUTHORIZATION" => "Bearer wrong-token") + get "/admin/ingestors", {}, headers + expect(last_response.status).to eq(403) + end + + it "returns empty list when no ingestors exist" do + get "/admin/ingestors", {}, admin_headers + expect(last_response).to be_ok + body = JSON.parse(last_response.body) + expect(body["ingestors"]).to eq([]) + end + + it "returns ingestors with masked API keys" do + db = open_db + begin + PotatoMesh::Application.create_ingestor(db, name: "Test Ingestor") + ensure + db&.close + end + + get "/admin/ingestors", {}, admin_headers + expect(last_response).to be_ok + body = JSON.parse(last_response.body) + expect(body["ingestors"].length).to eq(1) + expect(body["ingestors"][0]["name"]).to eq("Test Ingestor") + expect(body["ingestors"][0]["api_key"]).to include("****") + end + + it "excludes inactive ingestors by default" do + db = open_db + begin + ingestor = PotatoMesh::Application.create_ingestor(db, name: "Active") + inactive = PotatoMesh::Application.create_ingestor(db, name: "Inactive") + PotatoMesh::Application.deactivate_ingestor(db, inactive["id"]) + ensure + db&.close + end + + get "/admin/ingestors", {}, admin_headers + body = JSON.parse(last_response.body) + expect(body["ingestors"].length).to eq(1) + expect(body["ingestors"][0]["name"]).to eq("Active") + end + + it "includes inactive ingestors when requested" do + db = open_db + begin + PotatoMesh::Application.create_ingestor(db, name: "Active") + inactive = PotatoMesh::Application.create_ingestor(db, name: "Inactive") + PotatoMesh::Application.deactivate_ingestor(db, inactive["id"]) + ensure + db&.close + end + + get "/admin/ingestors?include_inactive=1", {}, admin_headers + body = JSON.parse(last_response.body) + expect(body["ingestors"].length).to eq(2) + end + end + + describe "GET /admin/ingestors/:id" do + it "returns 404 for unknown ID" do + get "/admin/ingestors/unknown-id", {}, admin_headers + expect(last_response.status).to eq(404) + end + + it "returns ingestor details with masked API key" do + db = open_db + ingestor = nil + begin + ingestor = PotatoMesh::Application.create_ingestor( + db, + name: "Detail Test", + node_id: "!abc123", + contact_email: "test@example.com", + ) + ensure + db&.close + end + + get "/admin/ingestors/#{ingestor["id"]}", {}, admin_headers + expect(last_response).to be_ok + body = JSON.parse(last_response.body) + expect(body["name"]).to eq("Detail Test") + expect(body["node_id"]).to eq("!abc123") + expect(body["contact_email"]).to eq("test@example.com") + expect(body["api_key"]).to include("****") + end + end + + describe "POST /admin/ingestors" do + it "creates a new ingestor" do + payload = { + name: "New Ingestor", + node_id: "!newnode", + contact_email: "new@example.com", + contact_matrix: "@new:matrix.org", + } + + post "/admin/ingestors", payload.to_json, admin_headers + expect(last_response.status).to eq(201) + body = JSON.parse(last_response.body) + expect(body["name"]).to eq("New Ingestor") + expect(body["node_id"]).to eq("!newnode") + expect(body["api_key"]).to match(/\A[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\z/) + end + + it "creates ingestor with minimal fields" do + post "/admin/ingestors", {}.to_json, admin_headers + expect(last_response.status).to eq(201) + body = JSON.parse(last_response.body) + expect(body["id"]).to be_a(String) + expect(body["api_key"]).to be_a(String) + end + + it "returns 400 for invalid JSON" do + post "/admin/ingestors", "not json", admin_headers + expect(last_response.status).to eq(400) + end + end + + describe "PATCH /admin/ingestors/:id" do + it "updates ingestor fields" do + db = open_db + ingestor = nil + begin + ingestor = PotatoMesh::Application.create_ingestor(db, name: "Original") + ensure + db&.close + end + + payload = { name: "Updated Name", node_id: "!updated" } + patch "/admin/ingestors/#{ingestor["id"]}", payload.to_json, admin_headers + expect(last_response).to be_ok + body = JSON.parse(last_response.body) + expect(body["name"]).to eq("Updated Name") + expect(body["node_id"]).to eq("!updated") + end + + it "returns 404 for unknown ID" do + patch "/admin/ingestors/unknown", { name: "Test" }.to_json, admin_headers + expect(last_response.status).to eq(404) + end + + it "returns 400 when no fields provided" do + db = open_db + ingestor = nil + begin + ingestor = PotatoMesh::Application.create_ingestor(db, name: "Test") + ensure + db&.close + end + + patch "/admin/ingestors/#{ingestor["id"]}", {}.to_json, admin_headers + expect(last_response.status).to eq(400) + end + + it "returns 400 for invalid JSON" do + db = open_db + ingestor = nil + begin + ingestor = PotatoMesh::Application.create_ingestor(db, name: "Test") + ensure + db&.close + end + + patch "/admin/ingestors/#{ingestor["id"]}", "not json", admin_headers + expect(last_response.status).to eq(400) + end + end + + describe "POST /admin/ingestors/:id/regenerate-key" do + it "generates a new API key" do + db = open_db + ingestor = nil + begin + ingestor = PotatoMesh::Application.create_ingestor(db, name: "Regen Test") + ensure + db&.close + end + + original_key = ingestor["api_key"] + post "/admin/ingestors/#{ingestor["id"]}/regenerate-key", {}.to_json, admin_headers + expect(last_response).to be_ok + body = JSON.parse(last_response.body) + expect(body["api_key"]).not_to eq(original_key) + expect(body["api_key"]).to match(/\A[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\z/) + end + + it "returns 404 for unknown ID" do + post "/admin/ingestors/unknown/regenerate-key", {}.to_json, admin_headers + expect(last_response.status).to eq(404) + end + end + + describe "POST /admin/ingestors/:id/deactivate" do + it "deactivates an ingestor" do + db = open_db + ingestor = nil + begin + ingestor = PotatoMesh::Application.create_ingestor(db, name: "Deactivate Test") + ensure + db&.close + end + + post "/admin/ingestors/#{ingestor["id"]}/deactivate", {}.to_json, admin_headers + expect(last_response).to be_ok + body = JSON.parse(last_response.body) + expect(body["status"]).to eq("deactivated") + + # Verify ingestor is now inactive + db = open_db(readonly: true) + begin + found = PotatoMesh::Application.find_ingestor_by_id(db, ingestor["id"]) + expect(found["is_active"]).to be(false) + ensure + db&.close + end + end + + it "returns 404 for unknown ID" do + post "/admin/ingestors/unknown/deactivate", {}.to_json, admin_headers + expect(last_response.status).to eq(404) + end + end + + describe "POST /admin/ingestors/:id/reactivate" do + it "reactivates an ingestor" do + db = open_db + ingestor = nil + begin + ingestor = PotatoMesh::Application.create_ingestor(db, name: "Reactivate Test") + PotatoMesh::Application.deactivate_ingestor(db, ingestor["id"]) + ensure + db&.close + end + + post "/admin/ingestors/#{ingestor["id"]}/reactivate", {}.to_json, admin_headers + expect(last_response).to be_ok + body = JSON.parse(last_response.body) + expect(body["status"]).to eq("reactivated") + + # Verify ingestor is now active + db = open_db(readonly: true) + begin + found = PotatoMesh::Application.find_ingestor_by_id(db, ingestor["id"]) + expect(found["is_active"]).to be(true) + ensure + db&.close + end + end + + it "returns 404 for unknown ID" do + post "/admin/ingestors/unknown/reactivate", {}.to_json, admin_headers + expect(last_response.status).to eq(404) + end + end + + describe "DELETE /admin/ingestors/:id" do + it "permanently deletes an ingestor" do + db = open_db + ingestor = nil + begin + ingestor = PotatoMesh::Application.create_ingestor(db, name: "Delete Test") + ensure + db&.close + end + + delete "/admin/ingestors/#{ingestor["id"]}", {}, admin_headers + expect(last_response).to be_ok + body = JSON.parse(last_response.body) + expect(body["status"]).to eq("deleted") + + # Verify ingestor is gone + db = open_db(readonly: true) + begin + found = PotatoMesh::Application.find_ingestor_by_id(db, ingestor["id"]) + expect(found).to be_nil + ensure + db&.close + end + end + + it "returns 404 for unknown ID" do + delete "/admin/ingestors/unknown", {}, admin_headers + expect(last_response.status).to eq(404) + end + end +end + +RSpec.describe "Ingestor Token Authentication" do + include Rack::Test::Methods + + let(:app) { Sinatra::Application } + let(:api_token) { "main-api-token" } + + around do |example| + Dir.mktmpdir("ingestor-auth-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(:default_db_path).and_return(db_path) + allow(PotatoMesh::Config).to receive(:legacy_db_path).and_return(db_path) + allow(PotatoMesh::Config).to receive(:ingestor_management_enabled?).and_return(true) + + FileUtils.mkdir_p(File.dirname(db_path)) + PotatoMesh::Application.init_db + + example.run + end + end + end + + def open_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") + db + end + + describe "require_token! with ingestor keys" do + around do |example| + original_token = ENV["API_TOKEN"] + ENV["API_TOKEN"] = api_token + begin + example.run + ensure + if original_token + ENV["API_TOKEN"] = original_token + else + ENV.delete("API_TOKEN") + end + end + end + + it "accepts valid ingestor API key" do + db = open_db + ingestor = nil + begin + ingestor = PotatoMesh::Application.create_ingestor(db, name: "Auth Test") + ensure + db&.close + end + + headers = { + "CONTENT_TYPE" => "application/json", + "HTTP_AUTHORIZATION" => "Bearer #{ingestor["api_key"]}", + } + payload = { "!test123" => { "id" => "!test123", "shortName" => "TEST" } } + + post "/api/nodes", payload.to_json, headers + expect(last_response).to be_ok + end + + it "rejects inactive ingestor API key" do + db = open_db + ingestor = nil + begin + ingestor = PotatoMesh::Application.create_ingestor(db, name: "Inactive Auth") + PotatoMesh::Application.deactivate_ingestor(db, ingestor["id"]) + ensure + db&.close + end + + headers = { + "CONTENT_TYPE" => "application/json", + "HTTP_AUTHORIZATION" => "Bearer #{ingestor["api_key"]}", + } + payload = { "!test123" => { "id" => "!test123", "shortName" => "TEST" } } + + post "/api/nodes", payload.to_json, headers + expect(last_response.status).to eq(403) + end + + it "records ingestor request activity" do + db = open_db + ingestor = nil + begin + ingestor = PotatoMesh::Application.create_ingestor(db, name: "Activity Test") + ensure + db&.close + end + + headers = { + "CONTENT_TYPE" => "application/json", + "HTTP_AUTHORIZATION" => "Bearer #{ingestor["api_key"]}", + "HTTP_X_INGESTOR_VERSION" => "1.2.3", + } + payload = { "!test123" => { "id" => "!test123", "shortName" => "TEST" } } + + post "/api/nodes", payload.to_json, headers + expect(last_response).to be_ok + + # Verify activity was recorded + db = open_db(readonly: true) + begin + updated = PotatoMesh::Application.find_ingestor_by_id(db, ingestor["id"]) + expect(updated["last_request_time"]).to be > 0 + expect(updated["request_count"]).to eq(1) + expect(updated["version"]).to eq("1.2.3") + ensure + db&.close + end + end + + it "still accepts main API_TOKEN" do + headers = { + "CONTENT_TYPE" => "application/json", + "HTTP_AUTHORIZATION" => "Bearer #{api_token}", + } + payload = { "!test123" => { "id" => "!test123", "shortName" => "TEST" } } + + post "/api/nodes", payload.to_json, headers + expect(last_response).to be_ok + end + end +end