mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
* fix(web): harden, polish and optimize the web UI per the September 2026 audit Works through docs/archive/WEB_UI_AUDIT_2026-09.md (health 8/20). Implementation integrity (P0) - app.css now defines every utility class the templates and JS use, including .hidden, so the ~145 JS show/hide toggles work. Button reset, and base component rules (.btn, .form-control) wrapped in :where() so utility classes on the same element win. New static-audit test fails when a used utility class has no rule. Accessibility - Focus rings render (the old ring rule referenced undefined variables); one :focus-visible outline everywhere; skip link; labelled nav landmarks. - Shared dialog helper (js/utils/dialog.js): role/aria-modal, focus trap, Escape, focus return, applied to every modal. - Named icon-only buttons and labelled ~70 form fields. - Toasts announced once; errors persist >= 10s; one showNotification. - Captive WiFi page: live region, timeouts, dark mode, 16px inputs. Performance (Pi Zero 2 W) - SSE streams and tab timers pause when hidden or off-tab; the display stream only runs while a preview is visible. app-shell.js deferred. - Widget scripts served as one versioned bundle (/assets/widgets.js): 52 -> 21 script tags, 66 -> 35 requests on first load. - Stdlib gzip fallback when flask-compress is missing: first-load JS/CSS 1358 KB -> 291 KB on the wire. SSE untouched. Theming and responsive - File managers, form fields and Fonts upload on theme tokens; bare inputs themed in dark mode; no more white surfaces. - No horizontal overflow at 375px on any tab; 44px touch targets on coarse pointers; reduced-motion respected; header title truncates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): clear Codacy findings on #568 - json-file-manager: focus-trap releases kept in a Map (no dynamic property access or delete; no value-returning forEach callback) - notification / schedule-picker: style and day-label lookups via Map - app.js: move the pending-queue assignment out of the expression - diff_viewer / error_handler: named function declarations instead of arrow consts No behavior change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test: check the OAuth widget ships in the widget bundle base.html no longer tags widget scripts one by one; they load through /assets/widgets.js. Assert the page requests the bundle and the bundle contains google-oauth.js, which is what the test was protecting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): address review feedback on #568 - widget bundle version fingerprints every file (name, mtime_ns, size) - gzip fallback appends Accept-Encoding to an existing Vary header - dialog helper: releasing a non-top dialog no longer moves focus out of the dialog the user is in - labels: file-upload targets its file input; fallback config fields get label for/id pairs; native color input has a fallback name - utility audit also reads class names inside bound :class expressions Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): give the native color-picker input an accessible name CodeRabbit flagged this on PR #568 as an outside-diff finding (never posted inline, so it was missed in the round of fixes that addressed the other 6 review comments). The <input type="color"> only carried a title attribute; screen readers don't reliably announce title, and there's no other label naming the control when showHexInput is false. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): clear Codacy findings in app-shell.js - drop the unused catch binding on the SSE JSON parse - move the pending-notification queue assignment out of the expression No behavior change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): contain plugin widgets/ dir and bound style-editor retries From CodeRabbit review on #568 (code that arrived with the main merge): - serve_plugin_widget resolves widgets/ with resolve_under before resolving the manifest script under it, so a symlinked widgets directory can't become the containment base (CWE-22). New test. - style-editor init stops polling after ~10s when the widget never registers and leaves the plain fallback fields in place. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
434 lines
20 KiB
Python
434 lines
20 KiB
Python
"""Tests the calendar plugin's OAuth and calendar-listing endpoints.
|
|
|
|
The plugin's config UI advertised a three-step setup, but only step 1 existed
|
|
on the server. Step 3's picker fetched /api/v3/plugins/calendar/list-calendars,
|
|
which was never registered, so Flask fell through to the global 404 handler and
|
|
the user saw "Resource not found" — with nothing to say which resource. Step 2
|
|
had no endpoint either, and no field in the schema at all, even though the
|
|
plugin ships calendar_registration.py written expressly for a web-driven
|
|
two-step flow.
|
|
|
|
These cover the two new routes: that they exist, that they fail with something
|
|
actionable rather than a bare 404, and that the shapes the widgets consume are
|
|
what the server actually sends.
|
|
"""
|
|
|
|
import json
|
|
import pickle
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
project_root = Path(__file__).parent.parent.parent
|
|
sys.path.insert(0, str(project_root))
|
|
|
|
from web_interface.blueprints import api_v3 as mod # noqa: E402
|
|
|
|
|
|
@pytest.fixture
|
|
def client(monkeypatch, tmp_path):
|
|
"""A test client whose calendar plugin lives in tmp_path."""
|
|
from flask import Flask
|
|
|
|
plugin_dir = tmp_path / 'calendar'
|
|
plugin_dir.mkdir()
|
|
|
|
app = Flask(__name__)
|
|
app.register_blueprint(mod.api_v3, url_prefix='/api/v3')
|
|
app.config['TESTING'] = True
|
|
monkeypatch.setattr(mod, '_calendar_plugin_dir', lambda: plugin_dir)
|
|
with app.test_client() as c:
|
|
c.plugin_dir = plugin_dir
|
|
yield c
|
|
|
|
|
|
@pytest.fixture
|
|
def uninstalled(monkeypatch):
|
|
from flask import Flask
|
|
|
|
app = Flask(__name__)
|
|
app.register_blueprint(mod.api_v3, url_prefix='/api/v3')
|
|
app.config['TESTING'] = True
|
|
monkeypatch.setattr(mod, '_calendar_plugin_dir', lambda: None)
|
|
with app.test_client() as c:
|
|
yield c
|
|
|
|
|
|
class TestTheRoutesExistAtAll:
|
|
"""The original bug: the URLs the widgets call were not registered."""
|
|
|
|
def test_list_calendars_is_routed(self, client):
|
|
response = client.get('/api/v3/plugins/calendar/list-calendars')
|
|
# Reaching the handler is the whole point; what it then says about
|
|
# missing setup is TestItSaysWhatIsWrong's business.
|
|
assert response.status_code != 404, "still unrouted"
|
|
assert response.get_json()['message'] != 'Resource not found'
|
|
|
|
def test_authenticate_is_routed(self, client):
|
|
response = client.post('/api/v3/plugins/calendar/authenticate', json={})
|
|
assert response.status_code != 404, "still unrouted"
|
|
assert response.get_json()['message'] != 'Resource not found'
|
|
|
|
def test_both_urls_match_what_the_widgets_request(self):
|
|
# The widgets hardcode these; a rename on either side reintroduces the
|
|
# original bug silently.
|
|
picker = Path(project_root) / 'web_interface/static/v3/js/widgets/google-calendar-picker.js'
|
|
oauth = Path(project_root) / 'web_interface/static/v3/js/widgets/google-oauth.js'
|
|
assert '/api/v3/plugins/calendar/list-calendars' in picker.read_text(encoding='utf-8')
|
|
assert '/api/v3/plugins/calendar/authenticate' in oauth.read_text(encoding='utf-8')
|
|
# api_v3 is a package now, so the route strings are spread across its
|
|
# modules; read the whole directory rather than one file.
|
|
pkg = Path(project_root) / 'web_interface/blueprints/api_v3'
|
|
source = "\n".join(f.read_text(encoding='utf-8') for f in sorted(pkg.glob('*.py')))
|
|
assert "'/plugins/calendar/list-calendars'" in source
|
|
assert "'/plugins/calendar/authenticate'" in source
|
|
|
|
def test_the_oauth_widget_is_dispatched_not_rendered_as_a_text_box(self):
|
|
# The string branch of the config template dispatches on an allow-list
|
|
# of widget names; anything missing from it silently falls through to a
|
|
# plain <input type="text">. That produced two boxes on the calendar
|
|
# page -- the widget's own, and a stray one for the same field -- and
|
|
# no way to tell which to paste into.
|
|
template = (Path(project_root)
|
|
/ 'web_interface/templates/v3/partials/plugin_config.html'
|
|
).read_text(encoding='utf-8')
|
|
allow_list_line = [ln for ln in template.splitlines()
|
|
if "str_widget in [" in ln]
|
|
assert allow_list_line, "the string widget allow-list moved"
|
|
assert "'google-oauth'" in allow_list_line[0], allow_list_line[0]
|
|
|
|
def test_the_widget_script_is_served(self):
|
|
# Widgets load through one bundle (web_interface/widget_bundle.py), so
|
|
# the page must request the bundle and the bundle must carry the file.
|
|
from web_interface import widget_bundle
|
|
base = (Path(project_root) / 'web_interface/templates/v3/base.html'
|
|
).read_text(encoding='utf-8')
|
|
assert 'widgets_bundle_url()' in base
|
|
assert 'google-oauth.js' in widget_bundle.BUNDLE_ORDER
|
|
body, _version = widget_bundle.build_bundle()
|
|
assert '/* google-oauth.js */' in body
|
|
|
|
def test_the_status_line_is_announced(self):
|
|
# Every message the widget gives arrives after an async call, so a
|
|
# screen reader hears nothing unless the element is a live region.
|
|
widget = (Path(project_root)
|
|
/ 'web_interface/static/v3/js/widgets/google-oauth.js'
|
|
).read_text(encoding='utf-8')
|
|
# Both attributes must be on the *status* element. Searching for them
|
|
# separately would pass with each on a different node, which announces
|
|
# nothing.
|
|
assert "status.setAttribute('role', 'status')" in widget, widget[:0]
|
|
assert "status.setAttribute('aria-live', 'polite')" in widget
|
|
|
|
def test_the_paste_box_has_an_accessible_name(self):
|
|
# A visible label is not enough on its own: without the association the
|
|
# input's only name is a placeholder, which vanishes on focus -- which
|
|
# is exactly when the value is being pasted.
|
|
widget = (Path(project_root)
|
|
/ 'web_interface/static/v3/js/widgets/google-oauth.js'
|
|
).read_text(encoding='utf-8')
|
|
# The binding is what matters, not that both lines exist: a `for` and
|
|
# an `id` that disagree leave the input just as anonymous. Both must
|
|
# go through the same identifier.
|
|
import re as _re
|
|
for_target = _re.search(r"codeLabel\.setAttribute\('for',\s*(\w+)\)", widget)
|
|
id_source = _re.search(r"codeInput\.id\s*=\s*(\w+)", widget)
|
|
assert for_target and id_source, (for_target, id_source)
|
|
assert for_target.group(1) == id_source.group(1), (
|
|
"label points at %r but the input is %r"
|
|
% (for_target.group(1), id_source.group(1)))
|
|
|
|
def test_the_failed_page_is_called_out_loudly(self):
|
|
# The loopback redirect lands on a browser error page at exactly the
|
|
# moment the user has to act. In small grey text it gets missed and the
|
|
# flow reads as broken while it is working.
|
|
widget = (Path(project_root)
|
|
/ 'web_interface/static/v3/js/widgets/google-oauth.js'
|
|
).read_text(encoding='utf-8')
|
|
assert 'expected' in widget.lower()
|
|
assert 'amber' in widget, "the warning is not visually distinguished"
|
|
|
|
|
|
class TestItSaysWhatIsWrong:
|
|
def test_listing_without_a_token_asks_for_step_2(self, client):
|
|
response = client.get('/api/v3/plugins/calendar/list-calendars')
|
|
assert response.status_code == 400
|
|
body = response.get_json()
|
|
assert body['status'] == 'error'
|
|
assert 'step 2' in body['message'].lower(), body['message']
|
|
|
|
def test_authenticating_without_credentials_asks_for_step_1(self, client):
|
|
response = client.post('/api/v3/plugins/calendar/authenticate', json={})
|
|
assert response.status_code == 400
|
|
assert 'step 1' in response.get_json()['message'].lower()
|
|
|
|
def test_an_uninstalled_plugin_says_so(self, uninstalled):
|
|
for response in (
|
|
uninstalled.get('/api/v3/plugins/calendar/list-calendars'),
|
|
uninstalled.post('/api/v3/plugins/calendar/authenticate', json={}),
|
|
):
|
|
assert response.status_code == 404
|
|
# A 404 here is honest -- but it must name the plugin, not read as
|
|
# the generic "Resource not found" that started this.
|
|
assert 'not installed' in response.get_json()['message'].lower()
|
|
|
|
|
|
class TestTheScriptRunner:
|
|
def test_it_returns_the_json_the_script_prints(self, tmp_path):
|
|
script = tmp_path / 'calendar_registration.py'
|
|
script.write_text(
|
|
'print(\'{"status": "success", "auth_url": "https://x"}\')\n',
|
|
encoding='utf-8')
|
|
payload, error = mod._run_calendar_registration(tmp_path, '')
|
|
assert error is None
|
|
assert payload['auth_url'] == 'https://x'
|
|
|
|
def test_it_ignores_noise_before_the_json(self, tmp_path):
|
|
# An import warning or a library writing to stdout would otherwise
|
|
# make the last-line parse fail.
|
|
script = tmp_path / 'calendar_registration.py'
|
|
script.write_text(
|
|
'print("some library warning")\n'
|
|
'print(\'{"status": "success"}\')\n', encoding='utf-8')
|
|
payload, error = mod._run_calendar_registration(tmp_path, '')
|
|
assert error is None and payload['status'] == 'success'
|
|
|
|
def test_it_passes_stdin_through(self, tmp_path):
|
|
script = tmp_path / 'calendar_registration.py'
|
|
script.write_text(
|
|
'import sys, json\n'
|
|
'print(json.dumps({"status": "success", "got": sys.stdin.read().strip()}))\n',
|
|
encoding='utf-8')
|
|
payload, _ = mod._run_calendar_registration(tmp_path, 'http://127.0.0.1/?code=abc')
|
|
assert payload['got'] == 'http://127.0.0.1/?code=abc'
|
|
|
|
def test_a_missing_script_is_reported(self, tmp_path):
|
|
payload, error = mod._run_calendar_registration(tmp_path, '')
|
|
assert payload is None
|
|
assert 'script not found' in error.lower()
|
|
|
|
def test_output_that_is_not_json_is_reported_with_context(self, tmp_path):
|
|
script = tmp_path / 'calendar_registration.py'
|
|
script.write_text('import sys\nsys.stderr.write("boom\\n")\n', encoding='utf-8')
|
|
payload, error = mod._run_calendar_registration(tmp_path, '')
|
|
assert payload is None
|
|
assert 'no result' in error.lower()
|
|
assert 'boom' in error
|
|
|
|
|
|
class TestListingShape:
|
|
"""The picker reads cal.id, cal.summary and cal.primary."""
|
|
|
|
def _authenticate(self, client, monkeypatch, items):
|
|
creds = type('C', (), {'expired': False, 'refresh_token': None, 'valid': True})()
|
|
(client.plugin_dir / 'token.pickle').write_bytes(pickle.dumps({'x': 1}))
|
|
monkeypatch.setattr(mod.pickle if hasattr(mod, 'pickle') else pickle,
|
|
'loads', lambda *a, **k: creds, raising=False)
|
|
|
|
import types
|
|
fake_pickle = types.SimpleNamespace(load=lambda f: creds, dump=lambda *a: None)
|
|
# Callers pass a flat list of calendars; the API returns them wrapped
|
|
# in a page. One page is all these cases need -- TestPagination builds
|
|
# its own multi-page sequences.
|
|
pages = [{'items': items}]
|
|
|
|
state = {'i': 0}
|
|
|
|
def fake_list(**kwargs):
|
|
page = pages[min(state['i'], len(pages) - 1)]
|
|
state['i'] += 1
|
|
return types.SimpleNamespace(execute=lambda: page)
|
|
|
|
def fake_build(*args, **kwargs):
|
|
return types.SimpleNamespace(
|
|
calendarList=lambda: types.SimpleNamespace(list=fake_list))
|
|
|
|
real_import = __builtins__['__import__'] if isinstance(__builtins__, dict) \
|
|
else __builtins__.__import__
|
|
|
|
def fake_import(name, *args, **kwargs):
|
|
if name == 'pickle':
|
|
return fake_pickle
|
|
if name == 'google.auth.transport.requests':
|
|
return types.SimpleNamespace(Request=object)
|
|
if name == 'googleapiclient.discovery':
|
|
return types.SimpleNamespace(build=fake_build)
|
|
return real_import(name, *args, **kwargs)
|
|
|
|
monkeypatch.setattr('builtins.__import__', fake_import)
|
|
|
|
def test_it_returns_id_summary_and_primary(self, client, monkeypatch):
|
|
self._authenticate(client, monkeypatch, [
|
|
{'id': 'b@x', 'summary': 'Work'},
|
|
{'id': 'a@x', 'summary': 'Personal', 'primary': True},
|
|
])
|
|
body = client.get('/api/v3/plugins/calendar/list-calendars').get_json()
|
|
assert body['status'] == 'success'
|
|
assert {c['id'] for c in body['calendars']} == {'a@x', 'b@x'}
|
|
assert all(set(c) == {'id', 'summary', 'primary'} for c in body['calendars'])
|
|
|
|
def test_the_primary_calendar_comes_first(self, client, monkeypatch):
|
|
# Short list, but the one the user wants is almost always their own.
|
|
self._authenticate(client, monkeypatch, [
|
|
{'id': 'z@x', 'summary': 'Aardvarks'},
|
|
{'id': 'a@x', 'summary': 'Zebras', 'primary': True},
|
|
])
|
|
body = client.get('/api/v3/plugins/calendar/list-calendars').get_json()
|
|
assert body['calendars'][0]['id'] == 'a@x'
|
|
assert body['calendars'][0]['primary'] is True
|
|
|
|
def test_a_calendar_without_a_name_still_lists(self, client, monkeypatch):
|
|
self._authenticate(client, monkeypatch, [{'id': 'noname@x'}])
|
|
body = client.get('/api/v3/plugins/calendar/list-calendars').get_json()
|
|
assert body['calendars'][0]['summary'] == 'noname@x'
|
|
|
|
def test_entries_without_an_id_are_dropped(self, client, monkeypatch):
|
|
# Nothing could be selected by such a row, and the checkbox value
|
|
# would be undefined.
|
|
self._authenticate(client, monkeypatch, [{'summary': 'ghost'}, {'id': 'real@x'}])
|
|
body = client.get('/api/v3/plugins/calendar/list-calendars').get_json()
|
|
assert [c['id'] for c in body['calendars']] == ['real@x']
|
|
|
|
|
|
class TestPagination:
|
|
"""calendarList.list pages at 250 and defaults to 100."""
|
|
|
|
def _paged(self, client, monkeypatch, pages):
|
|
import types
|
|
creds = type('C', (), {'expired': False, 'refresh_token': None, 'valid': True})()
|
|
(client.plugin_dir / 'token.pickle').write_bytes(b'x')
|
|
state = {'i': 0}
|
|
seen = []
|
|
|
|
def fake_list(**kwargs):
|
|
seen.append(kwargs)
|
|
page = pages[min(state['i'], len(pages) - 1)]
|
|
state['i'] += 1
|
|
return types.SimpleNamespace(execute=lambda: page)
|
|
|
|
def fake_build(*args, **kwargs):
|
|
return types.SimpleNamespace(
|
|
calendarList=lambda: types.SimpleNamespace(list=fake_list))
|
|
|
|
real_import = __builtins__['__import__'] if isinstance(__builtins__, dict) \
|
|
else __builtins__.__import__
|
|
|
|
def fake_import(name, *args, **kwargs):
|
|
if name == 'pickle':
|
|
return types.SimpleNamespace(load=lambda f: creds, dump=lambda *a: None)
|
|
if name == 'google.auth.transport.requests':
|
|
return types.SimpleNamespace(Request=object)
|
|
if name == 'googleapiclient.discovery':
|
|
return types.SimpleNamespace(build=fake_build)
|
|
return real_import(name, *args, **kwargs)
|
|
|
|
monkeypatch.setattr('builtins.__import__', fake_import)
|
|
return seen
|
|
|
|
def test_every_page_is_collected(self, client, monkeypatch):
|
|
# Taking only the first page would hide calendars from the picker with
|
|
# nothing to say the list was cut short.
|
|
self._paged(client, monkeypatch, [
|
|
{'items': [{'id': 'a@x', 'summary': 'A'}], 'nextPageToken': 't1'},
|
|
{'items': [{'id': 'b@x', 'summary': 'B'}], 'nextPageToken': 't2'},
|
|
{'items': [{'id': 'c@x', 'summary': 'C'}]},
|
|
])
|
|
body = client.get('/api/v3/plugins/calendar/list-calendars').get_json()
|
|
assert [c['id'] for c in body['calendars']] == ['a@x', 'b@x', 'c@x']
|
|
|
|
def test_the_page_token_is_passed_back(self, client, monkeypatch):
|
|
seen = self._paged(client, monkeypatch, [
|
|
{'items': [{'id': 'a@x', 'summary': 'A'}], 'nextPageToken': 'tok'},
|
|
{'items': [{'id': 'b@x', 'summary': 'B'}]},
|
|
])
|
|
client.get('/api/v3/plugins/calendar/list-calendars')
|
|
assert seen[0]['pageToken'] is None
|
|
assert seen[1]['pageToken'] == 'tok'
|
|
assert all(k['maxResults'] == 250 for k in seen)
|
|
|
|
def test_a_looping_token_cannot_spin_forever(self, client, monkeypatch):
|
|
# Every page claims another follows.
|
|
self._paged(client, monkeypatch, [
|
|
{'items': [{'id': 'a@x', 'summary': 'A'}], 'nextPageToken': 'same'},
|
|
])
|
|
body = client.get('/api/v3/plugins/calendar/list-calendars').get_json()
|
|
assert body['status'] == 'success'
|
|
assert len(body['calendars']) <= mod._CALENDAR_LIST_MAX_PAGES
|
|
|
|
|
|
class TestDiagnosticsAreRedacted:
|
|
def test_script_stderr_is_redacted_on_the_way_out(self, tmp_path):
|
|
script = tmp_path / 'calendar_registration.py'
|
|
script.write_text(
|
|
'import sys\n'
|
|
'sys.stderr.write("boom client_secret=hunter2 more\\n")\n',
|
|
encoding='utf-8')
|
|
payload, error = mod._run_calendar_registration(tmp_path, '')
|
|
assert payload is None
|
|
assert 'hunter2' not in error, error
|
|
assert '<redacted>' in error, error
|
|
|
|
def test_script_stderr_is_redacted_in_the_log_too(self, tmp_path, caplog):
|
|
# Regression: the return value went through redact_text (asserted
|
|
# above), but the logger.error call right next to it logged `raw`
|
|
# verbatim -- a script that handles OAuth client secrets and can
|
|
# quote them in its stderr, landing unredacted in the log (CWE-532).
|
|
script = tmp_path / 'calendar_registration.py'
|
|
script.write_text(
|
|
'import sys\n'
|
|
'sys.stderr.write("boom client_secret=hunter2 more\\n")\n',
|
|
encoding='utf-8')
|
|
with caplog.at_level('ERROR', logger=mod.logger.name):
|
|
mod._run_calendar_registration(tmp_path, '')
|
|
logged = '\n'.join(r.getMessage() for r in caplog.records)
|
|
assert 'hunter2' not in logged, logged
|
|
assert '<redacted>' in logged, logged
|
|
|
|
def test_a_failing_script_payload_is_redacted(self, client):
|
|
(client.plugin_dir / 'credentials.json').write_text('{}', encoding='utf-8')
|
|
(client.plugin_dir / 'calendar_registration.py').write_text(
|
|
'import json\n'
|
|
'print(json.dumps({"status": "error", '
|
|
'"message": "Failed: client_secret=topsecret"}))\n',
|
|
encoding='utf-8')
|
|
body = client.post('/api/v3/plugins/calendar/authenticate',
|
|
json={}).get_json()
|
|
assert body['status'] == 'error'
|
|
assert 'topsecret' not in json.dumps(body), body
|
|
assert '<redacted>' in body['message'], body
|
|
|
|
def test_an_unrunnable_script_is_reported_without_raw_exception_text(self,
|
|
tmp_path,
|
|
monkeypatch):
|
|
# OSError from the spawn carries the interpreter path and whatever the
|
|
# OS chose to say; it reaches the client through the redactor like
|
|
# everything else.
|
|
script = tmp_path / 'calendar_registration.py'
|
|
script.write_text('', encoding='utf-8')
|
|
|
|
def boom(*a, **k):
|
|
raise OSError("Exec format error: token=abcd1234 /usr/bin/python3")
|
|
|
|
monkeypatch.setattr(mod.subprocess, 'run', boom)
|
|
payload, error = mod._run_calendar_registration(tmp_path, '')
|
|
assert payload is None
|
|
assert 'abcd1234' not in error, error
|
|
assert 'OSError' in error, error
|
|
|
|
def test_a_missing_google_library_is_reported_without_raw_exception_text(
|
|
self, client, monkeypatch):
|
|
(client.plugin_dir / 'token.pickle').write_bytes(b'x')
|
|
real_import = __builtins__['__import__'] if isinstance(__builtins__, dict) \
|
|
else __builtins__.__import__
|
|
|
|
def fake_import(name, *args, **kwargs):
|
|
if name.startswith('google'):
|
|
raise ImportError("No module named 'google' password=hunter2")
|
|
return real_import(name, *args, **kwargs)
|
|
|
|
monkeypatch.setattr('builtins.__import__', fake_import)
|
|
body = client.get('/api/v3/plugins/calendar/list-calendars').get_json()
|
|
assert 'hunter2' not in json.dumps(body), body
|
|
assert 'requirements.txt' in body['message']
|