Secure send moves into Tech Tools
This commit is contained in:
@@ -12,7 +12,6 @@
|
||||
"notifications",
|
||||
"printedparts",
|
||||
"printers",
|
||||
"securesend",
|
||||
"slides",
|
||||
"tools",
|
||||
"usb",
|
||||
|
||||
@@ -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/<token>` | none | The recipient usually has no account here. The unguessable token IS the authorisation |
|
||||
| `GET /api/securesend/<token>` | 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.
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Securesend plugin package."""
|
||||
|
||||
from .plugin import SecuresendPlugin
|
||||
|
||||
__all__ = ['SecuresendPlugin']
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Securesend plugin API package."""
|
||||
|
||||
from .routes import securesend_bp
|
||||
|
||||
__all__ = ['securesend_bp']
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Securesend plugin models."""
|
||||
|
||||
from .securesend import SecureSecret
|
||||
|
||||
__all__ = ['SecureSecret']
|
||||
@@ -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')
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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__')
|
||||
5
plugins/tools/api/__init__.py
Normal file
5
plugins/tools/api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Tech Tools API package."""
|
||||
|
||||
from .routes import tools_bp
|
||||
|
||||
__all__ = ['tools_bp']
|
||||
@@ -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('/<token>', methods=['POST'])
|
||||
@tools_bp.route('/secrets/<token>', 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('/<token>', methods=['GET'])
|
||||
@tools_bp.route('/secrets/<token>', methods=['GET'])
|
||||
def peek_secret(token):
|
||||
"""Whether a link is still live, WITHOUT spending it.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
10
plugins/tools/models/__init__.py
Normal file
10
plugins/tools/models/__init__.py
Normal file
@@ -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']
|
||||
@@ -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 [
|
||||
|
||||
@@ -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'),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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/<token>', ['GET']),
|
||||
('/api/tools/secrets/<token>', ['POST']),
|
||||
], served
|
||||
|
||||
|
||||
def test_navigation_item():
|
||||
|
||||
@@ -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'])
|
||||
Reference in New Issue
Block a user