From a01f5c1a83e8ba5e42e0eca16492613490958f50 Mon Sep 17 00:00:00 2001 From: Cam Proudlock Date: Fri, 21 Aug 2026 14:18:10 -0400 Subject: [PATCH] Secure send moves into Tech Tools --- deploy/site-profile-universal.json | 1 - plugins/securesend/README.md | 71 ----------------- plugins/securesend/__init__.py | 5 -- plugins/securesend/api/__init__.py | 5 -- plugins/securesend/manifest.json | 9 --- plugins/securesend/models/__init__.py | 5 -- plugins/securesend/plugin.py | 76 ------------------- plugins/securesend/schemas/__init__.py | 6 -- plugins/securesend/tests/__init__.py | 0 plugins/securesend/tests/test_plugin.py | 30 -------- plugins/tools/api/__init__.py | 5 ++ plugins/{securesend => tools}/api/routes.py | 16 ++-- plugins/tools/manifest.json | 10 ++- .../{securesend => tools}/migrations/env.py | 6 +- .../migrations/script.py.mako | 0 .../versions/0001_securesend_baseline.py | 8 +- plugins/tools/models/__init__.py | 10 +++ .../models/securesend.py | 0 plugins/tools/plugin.py | 53 +++++++++---- shopdb/plugins/alembic_template.py | 4 +- tests/test_core/test_authz.py | 4 +- tests/test_plugin_migrations.py | 5 +- tests/test_plugins/test_tools.py | 36 ++++++++- ...securesend.py => test_tools_securesend.py} | 38 +++++----- 24 files changed, 139 insertions(+), 264 deletions(-) delete mode 100644 plugins/securesend/README.md delete mode 100644 plugins/securesend/__init__.py delete mode 100644 plugins/securesend/api/__init__.py delete mode 100644 plugins/securesend/manifest.json delete mode 100644 plugins/securesend/models/__init__.py delete mode 100644 plugins/securesend/plugin.py delete mode 100644 plugins/securesend/schemas/__init__.py delete mode 100644 plugins/securesend/tests/__init__.py delete mode 100644 plugins/securesend/tests/test_plugin.py create mode 100644 plugins/tools/api/__init__.py rename plugins/{securesend => tools}/api/routes.py (92%) rename plugins/{securesend => tools}/migrations/env.py (71%) rename plugins/{securesend => tools}/migrations/script.py.mako (100%) rename plugins/{securesend => tools}/migrations/versions/0001_securesend_baseline.py (89%) create mode 100644 plugins/tools/models/__init__.py rename plugins/{securesend => tools}/models/securesend.py (100%) rename tests/test_plugins/{test_securesend.py => test_tools_securesend.py} (82%) diff --git a/deploy/site-profile-universal.json b/deploy/site-profile-universal.json index ce96650e..64b7539e 100644 --- a/deploy/site-profile-universal.json +++ b/deploy/site-profile-universal.json @@ -12,7 +12,6 @@ "notifications", "printedparts", "printers", - "securesend", "slides", "tools", "usb", diff --git a/plugins/securesend/README.md b/plugins/securesend/README.md deleted file mode 100644 index 2ff7652e..00000000 --- a/plugins/securesend/README.md +++ /dev/null @@ -1,71 +0,0 @@ -# Securesend plugin - -One-view expiring links for handing someone a credential. The secret is -encrypted in the browser and the key never reaches this server. - -Replaces the habit it exists to kill: pasting a password into a chat message, -where it stays until somebody deletes the conversation. - -## What it is not - -**Not a document transfer.** Text only, with a hard size limit. See "The size -limit is the whole enforcement" below - that is a deliberate scope, not a -missing feature. - -**Not a Tech Tools page.** Tools is browser-only by design (`get_blueprint` and -`get_models` are both empty there); this needs storage, expiry and a view -counter, so it is its own plugin. - -**Not an asset.** A shared credential is a short-lived event, not something the -site inventories, so there is no assetid and no asset type. - -## Two doors - -| Route | Auth | Why | -|---|---|---| -| `POST /api/securesend` | login + `securesend.create` | Anonymous creation would be an untraceable way to move data out of a system that otherwise records who did what | -| `POST /api/securesend/` | none | The recipient usually has no account here. The unguessable token IS the authorisation | -| `GET /api/securesend/` | none | Reports whether a link is still live. Spends nothing | - -## Redeeming is a POST, and that is not an accident - -Teams, Outlook and Defender Safe Links prefetch GET urls to build previews and -scan for malware. If a GET redeemed the secret, the preview would consume it and -the recipient would open the link to find it already used. - -The GET route exists so the read page can say "a secret is waiting, and opening -it will destroy it" without spending the only view to find out. - -## The size limit is the whole enforcement - -The AES key lives in the URL fragment, which browsers never transmit, so the -server holds ciphertext it cannot read. It therefore cannot tell a password from -a page of a drawing, and `MAX_CIPHERTEXT` is not a tuning knob - it is the only -control a zero-knowledge design leaves on the server side. - -Everything else (line counts, file signatures, refusing a pasted file) belongs in -the browser, guides an honest person, and is bypassable by anyone willing to POST -directly. Raising the limit quietly turns this into the document transfer it was -scoped not to be. - -## What the audit trail can and cannot say - -It can say who created a secret, how big it was, when it was read and how often. -It cannot say what was in it - by design. That is the right trade for a -credential and the wrong one for a controlled document, which is the other reason -files are out of scope. - -## Not enumerable - -The token is 128 bits of randomness rendered as hex, never a sequence. Missing, -expired and spent all answer identically, so a guessed token learns nothing about -whether it ever named anything. - -## Before rolling it out - -**Test that your link rewriter preserves URL fragments.** Create a secret, send -the link to yourself in Teams, and click it from the message. If Safe Links -strips the `#`, the recipient gets a link with no key and the secret is -unrecoverable - the server cannot help, which is the point of the design. The -fallback if that happens is passphrase mode, where the key is derived from a -passphrase shared out of band. diff --git a/plugins/securesend/__init__.py b/plugins/securesend/__init__.py deleted file mode 100644 index 2e45a470..00000000 --- a/plugins/securesend/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Securesend plugin package.""" - -from .plugin import SecuresendPlugin - -__all__ = ['SecuresendPlugin'] diff --git a/plugins/securesend/api/__init__.py b/plugins/securesend/api/__init__.py deleted file mode 100644 index b254547a..00000000 --- a/plugins/securesend/api/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Securesend plugin API package.""" - -from .routes import securesend_bp - -__all__ = ['securesend_bp'] diff --git a/plugins/securesend/manifest.json b/plugins/securesend/manifest.json deleted file mode 100644 index e80ad9ef..00000000 --- a/plugins/securesend/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "securesend", - "version": "0.1.0", - "description": "One-view expiring links for handing someone a credential, encrypted in the browser", - "author": "", - "dependencies": [], - "core_version": ">=0.1.0,<1.0.0", - "api_prefix": "/api/securesend" -} diff --git a/plugins/securesend/models/__init__.py b/plugins/securesend/models/__init__.py deleted file mode 100644 index 474af6c3..00000000 --- a/plugins/securesend/models/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Securesend plugin models.""" - -from .securesend import SecureSecret - -__all__ = ['SecureSecret'] diff --git a/plugins/securesend/plugin.py b/plugins/securesend/plugin.py deleted file mode 100644 index 4619967b..00000000 --- a/plugins/securesend/plugin.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Securesend plugin main class. - -One-view expiring links for handing someone a credential, encrypted in the browser -""" - -import json -import logging -from pathlib import Path -from typing import List, Dict, Optional, Type - -from flask import Flask, Blueprint - -from shopdb.plugins.base import BasePlugin, PluginMeta - -from .models import SecureSecret -from .api import securesend_bp - -logger = logging.getLogger(__name__) - - -class SecuresendPlugin(BasePlugin): - """Securesend plugin. - - One-view expiring links for handing someone a credential, encrypted in the - browser. The key travels in the URL fragment and never reaches the server, - so what is stored here is ciphertext nobody on this side can read. - """ - - def __init__(self): - manifest_path = Path(__file__).parent / 'manifest.json' - with open(manifest_path) as f: - self._manifest = json.load(f) - - @property - def meta(self) -> PluginMeta: - return PluginMeta( - name=self._manifest['name'], - version=self._manifest['version'], - description=self._manifest['description'], - author=self._manifest.get('author', ''), - dependencies=self._manifest.get('dependencies', []), - core_version=self._manifest.get('core_version', '>=0.1.0'), - api_prefix=self._manifest.get('api_prefix'), - ) - - def get_blueprint(self) -> Optional[Blueprint]: - return securesend_bp - - def get_models(self) -> List[Type]: - return [SecureSecret] - - def get_permissions(self) -> List: - """One permission, on CREATION only. - - There is deliberately no read permission: the recipient of a secret - usually has no account here, and the unguessable token is what - authorises the read. Adding a permission to that side would make the - feature useless for the case it exists for - handing a credential to - somebody while you are on a call with them. - - No delete permission either. A secret is spent by being read or by - expiring, and both remove the row; a hand-delete route would only add a - way to destroy the audit trail of a secret that had not been read yet. - """ - return [ - ('securesend.create', 'Create shared secret links', 'securesend'), - ] - - def init_app(self, app: Flask, db_instance) -> None: - logger.info(f'Securesend plugin initialized (v{self.meta.version})') - - def on_install(self, app: Flask) -> None: - # NO ASSET TYPE. The scaffold assumes a plugin extends Asset, and this - # one does not: a shared credential is a short-lived event, not a thing - # the site owns and inventories. - logger.info('Securesend plugin installed') diff --git a/plugins/securesend/schemas/__init__.py b/plugins/securesend/schemas/__init__.py deleted file mode 100644 index 5a10bb2c..00000000 --- a/plugins/securesend/schemas/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Securesend plugin schemas (marshmallow). - -Add schema classes here when you need request/response validation -beyond the simple to_dict() output. The framework wires marshmallow -into the response helpers; see the PLUGIN-HOOKS wiki page for details. -""" diff --git a/plugins/securesend/tests/__init__.py b/plugins/securesend/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/plugins/securesend/tests/test_plugin.py b/plugins/securesend/tests/test_plugin.py deleted file mode 100644 index 6a70b743..00000000 --- a/plugins/securesend/tests/test_plugin.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Securesend plugin smoke tests. - -Asserts the plugin loads cleanly and satisfies the framework contract. -Replace and extend with domain tests as you build the plugin out. -""" - -from plugins.securesend.plugin import SecuresendPlugin - - -def test_securesend_plugin_meta_is_valid(): - """SecuresendPlugin.meta returns a PluginMeta with the expected name.""" - plugin = SecuresendPlugin() - assert plugin.meta.name == 'securesend' - assert plugin.meta.api_prefix == '/api/securesend' - - -def test_securesend_plugin_get_blueprint_returns_blueprint(): - """get_blueprint returns a Flask Blueprint, not None.""" - from flask import Blueprint - plugin = SecuresendPlugin() - assert isinstance(plugin.get_blueprint(), Blueprint) - - -def test_securesend_plugin_get_models_returns_a_model(): - """get_models returns a list with at least one SQLAlchemy model.""" - plugin = SecuresendPlugin() - models = plugin.get_models() - assert len(models) >= 1 - for model in models: - assert hasattr(model, '__tablename__') diff --git a/plugins/tools/api/__init__.py b/plugins/tools/api/__init__.py new file mode 100644 index 00000000..d0a9430e --- /dev/null +++ b/plugins/tools/api/__init__.py @@ -0,0 +1,5 @@ +"""Tech Tools API package.""" + +from .routes import tools_bp + +__all__ = ['tools_bp'] diff --git a/plugins/securesend/api/routes.py b/plugins/tools/api/routes.py similarity index 92% rename from plugins/securesend/api/routes.py rename to plugins/tools/api/routes.py index 15889a92..f55dd620 100644 --- a/plugins/securesend/api/routes.py +++ b/plugins/tools/api/routes.py @@ -1,4 +1,8 @@ -"""Create a one-view link, and redeem it. +"""Secure send: create a one-view link for a credential, and redeem it. + +Lives in Tech Tools because that is where somebody looks for a utility. It is +the one tool here that is NOT purely browser-side: a link that expires and dies +on first read needs somewhere to keep the ciphertext until then. TWO DIFFERENT DOORS, on purpose. @@ -33,7 +37,7 @@ from shopdb.api import ( from ..models import SecureSecret -securesend_bp = Blueprint('securesend', __name__) +tools_bp = Blueprint('tools', __name__) # Base64 of an encrypted credential. A password is well under a hundred # characters; this is generous for a connection string and useless for a @@ -53,9 +57,9 @@ def _utcnow(): return datetime.now(timezone.utc).replace(tzinfo=None) -@securesend_bp.route('', methods=['POST']) +@tools_bp.route('/secrets', methods=['POST']) @jwt_required() -@require_permission('securesend.create') +@require_permission('tools.secrets') def create_secret(): """Store an already-encrypted secret and return its token.""" data = request.get_json(silent=True) or {} @@ -118,7 +122,7 @@ def create_secret(): return success_response(secret.to_dict(), http_code=201) -@securesend_bp.route('/', methods=['POST']) +@tools_bp.route('/secrets/', methods=['POST']) def redeem_secret(token): """Hand over the ciphertext once, then spend the view. @@ -159,7 +163,7 @@ def redeem_secret(token): return success_response(payload) -@securesend_bp.route('/', methods=['GET']) +@tools_bp.route('/secrets/', methods=['GET']) def peek_secret(token): """Whether a link is still live, WITHOUT spending it. diff --git a/plugins/tools/manifest.json b/plugins/tools/manifest.json index 3e7b40a9..14e3d104 100644 --- a/plugins/tools/manifest.json +++ b/plugins/tools/manifest.json @@ -1,11 +1,15 @@ { "name": "tools", "version": "1.0.0", - "description": "Tech Tools: shop-floor utilities that run entirely in the browser (barcode/QR label generator, with room for more)", + "description": "Tech Tools: technician utilities, mostly browser-side (barcode/QR labels) plus secure send for handing somebody a credential by one-view link", "author": "ShopDB Team", "dependencies": [], "core_version": ">=0.16.0,<1.0.0", "provides": { - "features": ["code_generator"] - } + "features": [ + "code_generator", + "secure_send" + ] + }, + "api_prefix": "/api/tools" } diff --git a/plugins/securesend/migrations/env.py b/plugins/tools/migrations/env.py similarity index 71% rename from plugins/securesend/migrations/env.py rename to plugins/tools/migrations/env.py index 4ea69a3d..72e8c0a6 100644 --- a/plugins/securesend/migrations/env.py +++ b/plugins/tools/migrations/env.py @@ -1,15 +1,15 @@ -"""Alembic environment for the securesend plugin migration chain. +"""Alembic environment for the tools plugin migration chain. Delegates to the shared runner in shopdb.plugins.alembic_template, which filters the metadata to this plugin's tables and drives Alembic against the per-plugin -version table alembic_version_securesend. See ADR-008 for the ownership +version table alembic_version_tools. See ADR-008 for the ownership model. Unlike the ten cutover plugins whose 0001 is a no-op anchor, this plugin is NEW: its 0001 baseline really CREATES its tables, because the core chain never built them. """ import os -os.environ['PLUGIN_NAME'] = 'securesend' +os.environ['PLUGIN_NAME'] = 'tools' from shopdb.plugins.alembic_template import run_migrations # noqa: E402 diff --git a/plugins/securesend/migrations/script.py.mako b/plugins/tools/migrations/script.py.mako similarity index 100% rename from plugins/securesend/migrations/script.py.mako rename to plugins/tools/migrations/script.py.mako diff --git a/plugins/securesend/migrations/versions/0001_securesend_baseline.py b/plugins/tools/migrations/versions/0001_securesend_baseline.py similarity index 89% rename from plugins/securesend/migrations/versions/0001_securesend_baseline.py rename to plugins/tools/migrations/versions/0001_securesend_baseline.py index e03c8307..ebdc7dae 100644 --- a/plugins/securesend/migrations/versions/0001_securesend_baseline.py +++ b/plugins/tools/migrations/versions/0001_securesend_baseline.py @@ -1,6 +1,8 @@ -"""securesend plugin baseline. +"""tools plugin baseline: the secure send table. -Creates the one table this plugin owns. Not an asset extension: a shared +Creates the one table this plugin owns. Tech Tools was schemaless until secure +send arrived: a link that expires and dies on first read has to keep the +ciphertext somewhere until it is claimed. Not an asset extension: a shared credential is a short-lived event rather than a thing the site inventories, so there is no assetid and no asset type. @@ -14,7 +16,7 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision = 'securesend0001baseline' +revision = 'tools0001securesend' down_revision = None branch_labels = None depends_on = None diff --git a/plugins/tools/models/__init__.py b/plugins/tools/models/__init__.py new file mode 100644 index 00000000..7fd8d05f --- /dev/null +++ b/plugins/tools/models/__init__.py @@ -0,0 +1,10 @@ +"""Tech Tools models. + +Most tools run entirely in the browser and own nothing. Secure send is the +exception: handing somebody a credential needs storage, an expiry and a view +counter, so this plugin does carry one table. +""" + +from .securesend import SecureSecret + +__all__ = ['SecureSecret'] diff --git a/plugins/securesend/models/securesend.py b/plugins/tools/models/securesend.py similarity index 100% rename from plugins/securesend/models/securesend.py rename to plugins/tools/models/securesend.py diff --git a/plugins/tools/plugin.py b/plugins/tools/plugin.py index ae9c3610..a1cbc7fb 100644 --- a/plugins/tools/plugin.py +++ b/plugins/tools/plugin.py @@ -1,13 +1,19 @@ """Tech Tools plugin. -A section for small technician utilities. Every tool here runs entirely in the -browser: no API, no tables, no state. That is deliberate - these are the things -someone reaches for at a bench with no network guarantee, and an air-gapped -site gets them for free. +A section for small technician utilities. MOST tools here run entirely in the +browser - no API, no tables, no state - which is deliberate: these are the +things someone reaches for at a bench with no network guarantee, and an +air-gapped site gets them for free. -The plugin exists so the section can carry a nav entry and be included in or -left out of a per-site build (ADR-013). The tools themselves are frontend-only, -which is why get_blueprint and get_models are both empty. +SECURE SEND IS THE EXCEPTION, and it is why this plugin now has a blueprint, a +table and a migration chain where it once had none. A link that expires and dies +on first read cannot be browser-only: something has to hold the ciphertext until +it is claimed, and forget it afterwards. The secret itself is still encrypted in +the browser - the key travels in the URL fragment and never reaches this server - +so what is stored is ciphertext nobody on this side can read. + +The plugin also exists so the section can carry a nav entry and be included in or +left out of a per-site build (ADR-013). """ import logging @@ -17,30 +23,51 @@ from flask import Blueprint, Flask from shopdb.plugins.base import BasePlugin, PluginMeta +from .models import SecureSecret +from .api import tools_bp + logger = logging.getLogger(__name__) class ToolsPlugin(BasePlugin): - """Registers the Tech Tools section. All tools are client-side.""" + """Registers the Tech Tools section and the one tool that needs a server.""" @property def meta(self) -> PluginMeta: return PluginMeta( name='tools', version='1.0.0', - description='Tech Tools: browser-side technician utilities', + description=('Tech Tools: technician utilities, mostly browser-side ' + '(barcode/QR labels) plus secure send for handing ' + 'somebody a credential by one-view link'), author='ShopDB Team', dependencies=[], core_version='>=0.16.0,<1.0.0', + api_prefix='/api/tools', ) def get_blueprint(self) -> Optional[Blueprint]: - # No API surface. The tools never leave the browser. - return None + # Secure send only. Every other tool is still browser-side. + return tools_bp def get_models(self) -> List[Type]: - # No tables. Nothing here is persisted. - return [] + return [SecureSecret] + + def get_permissions(self) -> List: + """One permission, on CREATING a secret only. + + There is deliberately no read permission: the recipient usually has no + account here, and the unguessable token is what authorises the read. + Gating that side would make the tool useless for the case it exists for - + handing somebody a credential while you are on a call with them. + + No delete permission either. A secret is spent by being read or by + expiring, and both remove the row; a hand-delete route would only add a + way to destroy the record of a secret that had not been read yet. + """ + return [ + ('tools.secrets', 'Create secure send links', 'tools'), + ] def get_navigation_items(self) -> List[dict]: return [ diff --git a/shopdb/plugins/alembic_template.py b/shopdb/plugins/alembic_template.py index 702763d6..fa91fa24 100644 --- a/shopdb/plugins/alembic_template.py +++ b/shopdb/plugins/alembic_template.py @@ -67,8 +67,10 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = { 'printeditemfiles'), 'printers': ('printertypes', 'printers', 'modelsupplies', 'printerdrivers', 'printersupplyalerts', 'printerobservedqueues'), - 'securesend': ('securesendsecrets',), 'slides': ('tvslides',), + # Tech Tools was schemaless until secure send: every other tool in it + # runs in the browser and owns nothing. + 'tools': ('securesendsecrets',), 'usb': ('usbdevicetypes', 'usbdevices', 'usbcheckouts'), 'warranty': ('warranties', 'warrantyassets'), } diff --git a/tests/test_core/test_authz.py b/tests/test_core/test_authz.py index 22f5be48..476bb0f8 100644 --- a/tests/test_core/test_authz.py +++ b/tests/test_core/test_authz.py @@ -61,8 +61,8 @@ EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user', # rather than a GET because chat and mail clients prefetch # GET urls and a preview must not consume a one-view link. # CREATING one is permission-gated and IS swept. - # Justification in plugins/securesend/README.md. - 'securesend.redeem_secret'} + # Justification in plugins/tools/README.md. + 'tools.redeem_secret'} @pytest.fixture(autouse=True) diff --git a/tests/test_plugin_migrations.py b/tests/test_plugin_migrations.py index 8ed6670b..54654416 100644 --- a/tests/test_plugin_migrations.py +++ b/tests/test_plugin_migrations.py @@ -72,8 +72,9 @@ EXPECTED_HEAD_REVISION['network'] = 'network0003prefix' # the per-transaction revision column. EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0004txnrev' # Built after the ADR-008 cutover, so its 0001 genuinely creates the table -# rather than stamping an anchor. -EXPECTED_HEAD_REVISION['securesend'] = 'securesend0001baseline' +# rather than stamping an anchor. Tech Tools owns a table now because secure +# send needs one; every other tool in that plugin is browser-side. +EXPECTED_HEAD_REVISION['tools'] = 'tools0001securesend' # notifications indexes businessunitid, then adds the per-type grace window and # the shared board category. EXPECTED_HEAD_REVISION['notifications'] = 'notifications0005boardorder' diff --git a/tests/test_plugins/test_tools.py b/tests/test_plugins/test_tools.py index ac1e30dd..65e65fc7 100644 --- a/tests/test_plugins/test_tools.py +++ b/tests/test_plugins/test_tools.py @@ -27,11 +27,39 @@ def test_meta_matches_manifest(): assert meta.core_version == manifest['core_version'] -def test_no_backend_surface(): - """Client-side only: no blueprint to register, no tables to migrate.""" +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_blueprint() is None - assert plugin.get_models() == [] + 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(): diff --git a/tests/test_plugins/test_securesend.py b/tests/test_plugins/test_tools_securesend.py similarity index 82% rename from tests/test_plugins/test_securesend.py rename to tests/test_plugins/test_tools_securesend.py index 3a72887a..00c31161 100644 --- a/tests/test_plugins/test_securesend.py +++ b/tests/test_plugins/test_tools_securesend.py @@ -7,8 +7,8 @@ fail in ways nobody notices until somebody needs the password. import pytest -from plugins.securesend.models import SecureSecret -from plugins.securesend.api.routes import MAX_CIPHERTEXT +from plugins.tools.models import SecureSecret +from plugins.tools.api.routes import MAX_CIPHERTEXT CIPHER = 'ZmFrZS1jaXBoZXJ0ZXh0LWZvci10ZXN0cw==' IV = 'MTIzNDU2Nzg5MDEy' @@ -17,7 +17,7 @@ IV = 'MTIzNDU2Nzg5MDEy' def _create(client, headers, **overrides): body = {'ciphertext': CIPHER, 'iv': IV} body.update(overrides) - return client.post('/api/securesend', json=body, headers=headers) + return client.post('/api/tools/secrets', json=body, headers=headers) # ------------------------------------------------------------------- creating @@ -25,7 +25,7 @@ def _create(client, headers, **overrides): def test_creating_requires_a_login(client): """Anonymous creation would be an untraceable way to move data out of a system that otherwise records who did what.""" - resp = client.post('/api/securesend', json={'ciphertext': CIPHER, 'iv': IV}) + resp = client.post('/api/tools/secrets', json={'ciphertext': CIPHER, 'iv': IV}) assert resp.status_code in (401, 403, 422) @@ -74,29 +74,29 @@ def test_anyone_can_read_it_without_logging_in(client, auth_headers): """The recipient usually has no account here. The unguessable token is the authorisation.""" token = _create(client, auth_headers).get_json()['data']['token'] - resp = client.post('/api/securesend/%s' % token) + resp = client.post('/api/tools/secrets/%s' % token) assert resp.status_code == 200 assert resp.get_json()['data']['ciphertext'] == CIPHER def test_the_second_read_finds_nothing(client, auth_headers): token = _create(client, auth_headers).get_json()['data']['token'] - assert client.post('/api/securesend/%s' % token).status_code == 200 - assert client.post('/api/securesend/%s' % token).status_code == 404 + assert client.post('/api/tools/secrets/%s' % token).status_code == 200 + assert client.post('/api/tools/secrets/%s' % token).status_code == 404 def test_a_spent_secret_is_deleted_not_just_marked(client, db, auth_headers): """A secret that has been read has no reason to keep its ciphertext.""" token = _create(client, auth_headers).get_json()['data']['token'] - client.post('/api/securesend/%s' % token) + client.post('/api/tools/secrets/%s' % token) assert SecureSecret.query.filter_by(token=token).first() is None def test_more_than_one_view_can_be_allowed(client, auth_headers): token = _create(client, auth_headers, views=2).get_json()['data']['token'] - assert client.post('/api/securesend/%s' % token).status_code == 200 - assert client.post('/api/securesend/%s' % token).status_code == 200 - assert client.post('/api/securesend/%s' % token).status_code == 404 + assert client.post('/api/tools/secrets/%s' % token).status_code == 200 + assert client.post('/api/tools/secrets/%s' % token).status_code == 200 + assert client.post('/api/tools/secrets/%s' % token).status_code == 404 # ------------------------------------------------------- link previews (Teams) @@ -111,19 +111,19 @@ def test_a_get_does_not_spend_the_secret(client, auth_headers): """ token = _create(client, auth_headers).get_json()['data']['token'] - peek = client.get('/api/securesend/%s' % token) + peek = client.get('/api/tools/secrets/%s' % token) assert peek.status_code == 200 assert peek.get_json()['data']['available'] is True assert 'ciphertext' not in peek.get_json()['data'] # Still there for the person it was meant for. - assert client.post('/api/securesend/%s' % token).status_code == 200 + assert client.post('/api/tools/secrets/%s' % token).status_code == 200 def test_peeking_at_a_spent_link_says_so_without_leaking(client, auth_headers): token = _create(client, auth_headers).get_json()['data']['token'] - client.post('/api/securesend/%s' % token) - peek = client.get('/api/securesend/%s' % token) + client.post('/api/tools/secrets/%s' % token) + peek = client.get('/api/tools/secrets/%s' % token) assert peek.get_json()['data']['available'] is False @@ -137,7 +137,7 @@ def test_an_expired_secret_is_gone(client, db, auth_headers): - timedelta(minutes=1)) db.session.commit() - assert client.post('/api/securesend/%s' % token).status_code == 404 + assert client.post('/api/tools/secrets/%s' % token).status_code == 404 assert SecureSecret.query.filter_by(token=token).first() is None @@ -152,10 +152,10 @@ def test_an_unknown_token_answers_the_same_as_a_spent_one(client, auth_headers): """Distinguishing them would confirm to anyone holding a guessed token whether it ever named anything.""" token = _create(client, auth_headers).get_json()['data']['token'] - client.post('/api/securesend/%s' % token) + client.post('/api/tools/secrets/%s' % token) - spent = client.post('/api/securesend/%s' % token) - never = client.post('/api/securesend/%s' % ('f' * 32)) + spent = client.post('/api/tools/secrets/%s' % token) + never = client.post('/api/tools/secrets/%s' % ('f' * 32)) assert spent.status_code == never.status_code == 404 assert (spent.get_json()['data']['error']['message'] == never.get_json()['data']['error']['message'])