"""Tests for the Tech Tools plugin. The plugin owns no API and no tables, so the whole backend contract is: it loads, it advertises one nav entry, and it claims no database. That last part matters - a plugin that accidentally returns models would pull the tools section into the migration and prune-schema machinery it has no business in. """ import json import os from plugins.tools.plugin import ToolsPlugin PLUGIN_DIR = os.path.join(os.path.dirname(os.path.dirname( os.path.dirname(os.path.abspath(__file__)))), 'plugins', 'tools') def test_meta_matches_manifest(): """The manifest is the single source of truth per ADR-002.""" with open(os.path.join(PLUGIN_DIR, 'manifest.json'), encoding='utf-8') as handle: manifest = json.load(handle) meta = ToolsPlugin().meta assert meta.name == manifest['name'] assert meta.version == manifest['version'] assert meta.core_version == manifest['core_version'] def test_the_only_backend_surface_is_secure_send(): """Tech Tools used to have NO backend, and mostly still should. This asserted `get_blueprint() is None` until secure send arrived: a link that expires and dies on first read cannot be browser-only, because something has to hold the ciphertext until it is claimed. The guard is kept rather than deleted, narrowed to the one exception - a future tool that quietly grows an endpoint or a table has to change this test, and therefore make the decision on purpose rather than by accident. """ from flask import Flask from plugins.tools.models import SecureSecret plugin = ToolsPlugin() assert plugin.get_models() == [SecureSecret] probe = Flask(__name__) probe.register_blueprint(plugin.get_blueprint(), url_prefix='/api/tools') served = sorted( (str(rule), sorted(rule.methods - {'HEAD', 'OPTIONS'})) for rule in probe.url_map.iter_rules() if not str(rule).startswith('/static')) # Methods are asserted, not just paths: REDEEMING MUST BE POST-ONLY. Chat # and mail clients prefetch GET urls to build previews, and a preview that # could redeem would consume a one-view secret before its recipient ever # opened the link. The GET on the same path reports availability only. assert served == [ ('/api/tools/secrets', ['POST']), ('/api/tools/secrets/', ['GET']), ('/api/tools/secrets/', ['POST']), ], served def test_navigation_item(): items = ToolsPlugin().get_navigation_items() assert len(items) == 1 item = items[0] assert item['name'] == 'Tech Tools' assert item['route'] == '/tools' # The icon has to exist in AppLayout's iconMap or the nav entry renders # with no glyph. assert item['icon'] == 'wrench' def test_nav_icon_is_mapped_in_the_frontend(): """Guard the one cross-file coupling: nav icon name -> AppLayout iconMap.""" layout = os.path.join(os.path.dirname(PLUGIN_DIR), '..', 'frontend', 'src', 'views', 'AppLayout.vue') with open(os.path.normpath(layout), encoding='utf-8') as handle: source = handle.read() for item in ToolsPlugin().get_navigation_items(): assert f"'{item['icon']}':" in source