Files
simple-nanoshare/tests/test_text_view_route.py
daniel156161 3db374b1ab
Build and Push Docker Container / build-and-push (push) Successful in 2m37s
feat(files): cache served files and add HTML text viewer
File cache:
- Add my_modules/filecache with metadata, blob, validator and store layers
- Cache convex.get_file via AsyncCache, TTL clamped to the file expiry
- Skip caching for missing, incomplete or already expired records
- Add a process-local, size-bounded LRU for immutable storage blobs
- Build the cache stack from FILE_CACHE_* env vars in build_file_cache
- Attach the cache to the app in my_modules/app/setup.py
- Expose app-bound helpers in filecache/access.py for routes

Serving:
- Send strong ETag, Last-Modified and Cache-Control on served files
- Answer If-None-Match and If-Modified-Since with 304 from metadata only
- Avoid pulling the blob from Convex on a revalidation hit
- Add a short Cloudflare-CDN-Cache-Control edge window for deleted files
- Record cache_status in the wide event on a 304
- Invalidate cached metadata after file update and delete in both routes

Text viewer:
- Add my_modules/textview to render text/plain and text/markdown as HTML
- Linkify http(s) URLs and trim trailing sentence punctuation
- Resolve links pointing at this instance to the referenced file
- Inline image previews, mark missing and expired targets
- Cap resolution at MAX_LOOKUPS backend lookups per document
- Serve the viewer only for real browser navigations, never for curl or CLI
- Keep raw bytes for ?raw=1, downloads and Accept headers without text/html
- Add templates/side/views/webpage/files/text_view.htm with a raw link

Other:
- Add tests for cache layers, serving, text rendering and the route
- Bump multidict to 7.2.1 in uv.lock
- Update the my_helpers submodule pointer
2026-10-25 18:54:46 +01:00

171 lines
5.8 KiB
Python

import asyncio
import importlib.util
import sys
import types
from io import BytesIO
from pathlib import Path
from unittest.mock import AsyncMock
import pytest
from quart import Blueprint, Quart
ROOT = Path(__file__).resolve().parents[1]
BROWSER_ACCEPT = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
PASTE = (
'Ref Barend: https://i.example.dev/-Q6jmHoRYjFi\n'
'Foreign: https://other.example.dev/-abc\n'
'Script test: <script>alert(1)</script>\n'
)
FILES = {
'paste': {
'file_id': 'paste',
'file_name': 'pasted-2026-10-25T101010Z.txt',
'content_type': 'text/plain',
'db_image_url': 'storage://paste',
'uploaded_at': 1700000000000,
'user_id': 'owner',
},
'Q6jmHoRYjFi': {
'file_id': 'Q6jmHoRYjFi',
'file_name': 'barend.png',
'content_type': 'image/png',
'db_image_url': 'storage://barend',
'uploaded_at': 1700000000000,
'user_id': 'owner',
},
}
def load_share_module(monkeypatch):
stubs = {
'my_modules.decoratory.header': {'login_required': lambda fn: fn},
'my_modules.functions': {'get_ip': lambda: '192.0.2.1'},
'my_modules.app.setup': {
'LIMITER': types.SimpleNamespace(limit=lambda *args, **kwargs: lambda fn: fn),
},
'my_modules.app.logger': {'logger': None},
'my_modules.expiry': {'parse_expires': lambda value: value},
'quart_common.web.wide_event': {'add_wide_event_context': lambda **kwargs: None},
}
for name, values in stubs.items():
module = types.ModuleType(name)
module.__dict__.update(values)
monkeypatch.setitem(sys.modules, name, module)
path = ROOT / 'routes/side/main.py'
spec = importlib.util.spec_from_file_location('text_view_route_under_test', path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def build_app(monkeypatch, paste_body: bytes = PASTE.encode()):
module = load_share_module(monkeypatch)
app = Quart(
'text_view_test',
template_folder=str(ROOT / 'templates/side'),
static_folder=str(ROOT / 'templates/static'),
)
app.secret_key = 'test-secret'
async def get_file(file_id):
return FILES.get(file_id)
async def get_from_storage(storage_id):
return BytesIO(paste_body if storage_id == 'storage://paste' else b'binary')
app.convex = types.SimpleNamespace(
get_file=AsyncMock(side_effect=get_file),
get_from_storage=AsyncMock(side_effect=get_from_storage),
add_file_access=AsyncMock(return_value=None),
)
app.register_blueprint(module.side_main_bp)
# Minimal endpoints so base.htm/navigation.htm can resolve their url_for calls.
stub_bp = Blueprint('basic', __name__)
stub_bp.add_url_rule('/favicon.ico', 'favicon', lambda: '')
app.register_blueprint(stub_bp)
auth_bp = Blueprint('auth_login', __name__)
auth_bp.add_url_rule('/login', 'login', lambda: '')
app.register_blueprint(auth_bp)
composer_bp = Blueprint('link_composer', __name__)
composer_bp.add_url_rule('/links', 'link_composer_page', lambda: '')
app.register_blueprint(composer_bp)
return app
def get(app, path, headers=None):
async def run():
return await app.test_client().get(path, headers=headers or {})
return asyncio.run(run())
async def body_of(response):
return (await response.get_data()).decode()
def text_of(response):
return asyncio.run(body_of(response))
# --- browser view -------------------------------------------------------------
def test_browser_gets_html_with_clickable_links(monkeypatch):
app = build_app(monkeypatch)
response = get(app, '/-paste', {'Accept': BROWSER_ACCEPT, 'Host': 'i.example.dev'})
assert response.status_code == 200
assert response.content_type.startswith('text/html')
html = text_of(response)
assert '<a href="https://other.example.dev/-abc"' in html
assert 'rel="noopener noreferrer"' in html
def test_own_link_resolves_to_filename_and_thumbnail(monkeypatch):
app = build_app(monkeypatch)
html = text_of(get(app, '/-paste', {'Accept': BROWSER_ACCEPT, 'Host': 'i.example.dev'}))
assert 'barend.png' in html
assert '<img src="https://i.example.dev/-Q6jmHoRYjFi"' in html
def test_text_view_escapes_html_in_the_paste(monkeypatch):
app = build_app(monkeypatch)
html = text_of(get(app, '/-paste', {'Accept': BROWSER_ACCEPT, 'Host': 'i.example.dev'}))
assert '<script>alert(1)</script>' not in html
assert '&lt;script&gt;' in html
def test_text_view_renders_large_pastes_completely(monkeypatch):
marker = 'END-OF-PASTE-MARKER'
body = (('filler line\n' * 60_000) + marker).encode()
app = build_app(monkeypatch, paste_body=body)
html = text_of(get(app, '/-paste', {'Accept': BROWSER_ACCEPT, 'Host': 'i.example.dev'}))
assert marker in html
assert html.count('filler line') == 60_000
assert 'truncated' not in html.lower()
def test_text_view_is_not_cached(monkeypatch):
app = build_app(monkeypatch)
response = get(app, '/-paste', {'Accept': BROWSER_ACCEPT, 'Host': 'i.example.dev'})
assert response.headers['Cache-Control'] == 'private, no-store'
# --- raw fallbacks ------------------------------------------------------------
@pytest.mark.parametrize('path,headers', [
('/-paste', {'Accept': '*/*'}),
('/-paste', {'User-Agent': 'curl/8.0'}),
('/-paste?raw=1', {'Accept': BROWSER_ACCEPT}),
('/-paste?download=1', {'Accept': BROWSER_ACCEPT}),
])
def test_non_browser_requests_still_get_raw_text(monkeypatch, path, headers):
app = build_app(monkeypatch)
headers.setdefault('Host', 'i.example.dev')
response = get(app, path, headers)
assert response.status_code == 200
assert response.content_type.startswith('text/plain')
assert text_of(response) == PASTE
def test_binary_files_are_untouched(monkeypatch):
app = build_app(monkeypatch)
response = get(app, '/-Q6jmHoRYjFi', {'Accept': BROWSER_ACCEPT, 'Host': 'i.example.dev'})
assert response.status_code == 200
assert response.content_type.startswith('image/png')