mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
* refactor(api-v3): split the 10,469-line blueprint into a package web_interface/blueprints/api_v3.py held 111 routes, 56 helpers and 181 functions in one module -- 9% of the core by line count and three times the next largest file. It becomes a package of nine route modules grouped by path segment, plus __init__.py for the shared imports, constants, Blueprint and helpers. Every route module decorates the SAME api_v3 Blueprint object, so endpoint names stay api_v3.<function>, the URL map is unchanged and app.py is untouched. Verified: 111 routes before, 111 after, byte-identical rules, endpoints and methods, and every endpoint still on the one blueprint. plugins 3,867 config 1,178 starlark 692 system 619 fonts 452 misc 398 wifi 361 display 326 backup 212 __init__ 1,787 (imports, constants, Blueprint, 56 helpers) Two things the URL-map check could not catch, both found by running the suite: 1. PROJECT_ROOT = Path(__file__).parent.parent.parent. Moving the code one directory deeper made that resolve to web_interface/ instead of the project root. Nothing failed at import; it surfaced as ~110 tests failing with 404s and "installation script not found", because every path built from it was one level too shallow. Now parents[3], and test_api_v3_url_map.py asserts PROJECT_ROOT/run.py exists so the next move cannot repeat it. 2. Module-attribute patching. Tests do monkeypatch.setattr(api_v3_module, "_BACKUP_EXPORT_DIR", ...) and a route module that binds such a name by value never sees the patch. The shared code therefore stays in __init__.py rather than moving to a _common submodule -- it has to live on the module the tests patch -- and the eleven names tests patch are read back through the package (_pkg.X) instead of bound by value. Those eleven were found by AST-scanning every setattr in the test tree, not by guessing; "time" is among them, used to drive a fake clock through the second-resolution credential-backup filenames. Test changes are confined to what genuinely moved: patch targets that now name the owning route module, imports of helpers, and six tests that scan the api_v3 source as a file and now read the package directory. Full suite: 4,278 passed, 68 skipped, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 * fix(api-v3): address CodeRabbit findings from the blueprint-split review Fixes to the api_v3 package split (PR #553), one per finding verified against the actual code: - __init__.py: _redact_credentials only blanked scalar values under a credential-named key; a bare list of secrets under such a key (e.g. tokens: ["a", "b"]) passed through untouched, since the list branch recursed with no memory that its key looked like a credential. Nested dicts still walk normally (a documented, tested behaviour -- a container like secrets: {api_key: ..., note: ...} is a section name, not a value to blank outright), but any value reached under a credential-shaped key is now actually blanked. - __init__.py: the OAuth helper script's raw stderr/stdout went to logger.error unredacted (CWE-532) right next to a comment claiming this was deliberate; the HTTP response already used the existing redact_text helper. Routed the log line through the same helper. - __init__.py / starlark.py: the standalone Starlark manifest fallback (used when the plugin instance isn't loaded) read-modified-wrote manifest.json with no lock, unlike StarlarkAppsPlugin._update_manifest_safe (plugin-repos/starlark-apps/manager.py), which already holds an flock for the same file when the plugin is loaded. Added _starlark_manifest_lock, mirroring that pattern, and wrapped every standalone read-modify-write call site in it. The app-config update route also wrote config.json and the manifest as two separate, non-transactional writes (a second, distinct finding at the same call site); config.json is now rolled back if the manifest write that follows it fails. - backup.py: restore options used bare bool() on values from the request, so {"restore_secrets": "false"} restored secrets anyway (bool("false") is True). Switched to the existing _coerce_to_bool helper already used for this exact purpose elsewhere in the package. - config.py: an automated import-rewrite mangled four user-facing validation strings and their neighbouring comments -- "Invalid start time" had become "Invalid start _pkg.time" (and likewise for "end time") in both the schedule and dim-schedule per-day validation paths. - display.py: `import _pkg.time as time_module` -- _pkg is a local alias for the package, not a real importable module, so this raised ModuleNotFoundError whenever a caller restarted an already-running display service via /display/on-demand/start, after the on-demand request was already written to cache. Fixed to `import time`. Audited the rest of the package for the same `_pkg.<module>` import mistake; every other `_pkg.` reference is a legitimate attribute read-through (`_pkg.time.time()`, `_pkg._get_starlark_plugin()`, ...), not a broken import statement. - fonts.py: validate_file_upload's max_size_mb parameter is silently unused by that helper (it only checks filename/extension) -- the font upload route saved arbitrarily large files as a result. Added the same seek-and-check pattern already used for the sibling .star upload. - wifi.py: two ad hoc, inconsistent bool coercions. POST /wifi/ap/auto-enable used bare bool(), so a JSON string "false" enabled it. POST /wifi/radio's enabled/force parsing recognized real bool and some strings but not int 1/0 (1 is True is False in Python). Factored one small _parse_bool_ish helper local to this file and used it at all three sites. Not changed: the "unknown/misspelled restore option keys default to True" half of the backup.py finding -- the file's own comment documents that a missing key deliberately means "restore everything," matching the already-existing JSON-parse-failure guard a few lines above it; only the bool-coercion defect was a real bug. Added or extended regression tests for every fix, following each area's existing test conventions. Full suite: 4328 passed, 62 skipped, 2 failed on both this branch and origin/main (missing tzdata package breaks two timezone-alias tests in test_onboarding_checklist.py, unrelated to this change) -- no new failures. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S3bPMESe2TfrGvbs1ef9c5 * fix(api-v3): reject unknown restore option keys CodeRabbit's review of the blueprint split (#553) asked that POST /backup/restore reject option keys outside RestoreOptions' known set. The follow-up commit fixed the bool("false")-is-True bug with _coerce_to_bool but never added the key check: a typo'd or renamed key (e.g. "restoreSecrets") is silently ignored by opts_dict.get(key, True), so the flag stays at its True default and secrets get restored despite the caller's request saying otherwise -- with no indication anything was wrong. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vmcwf5vMgYqdt8bJTZtiwb * fix(api-v3): address CodeRabbit findings on the blueprint split - _redact_credentials: blank scalar descendants of objects reached through a credential-owned list (e.g. tokens: [{"value": "secret"}]) regardless of field name -- the existing name-based walk only protected direct dict values under a credential key, not list items. - wifi.py: reject enabled/force/auto_enable_ap_mode values _parse_bool_ish can't recognize (400) instead of silently treating them as False, which could disable Wi-Fi or the radio itself. - Starlark manifest locking: lock a stable manifest.json.lock sidecar instead of manifest.json itself, in both the standalone route path (_starlark_manifest_lock) and the plugin path (StarlarkAppsPlugin._save_manifest / _update_manifest_safe). manifest.json is replaced by an atomic rename on every write, which swaps in a fresh inode; a lock held on the old inode does not exclude a second locker that opens the path afresh right after the rename and gets the new inode, so two writers could race despite each holding "a lock". A sidecar that no write ever touches always resolves to the same inode for every locker. Skipped as stale: the "serialize the complete manifest read-modify-write" finding at api_v3/__init__.py -- every standalone handler that calls _write_starlark_manifest is already wrapped in _starlark_manifest_lock() on this branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(api-v3): re-check reconciliation findings by the reconciler's own rules Both CodeRabbit findings on the merge commit, verified against the code first. Major, plugins.py: the stale-findings filter derived its own notion of "in config" and "on disk", and both were looser than the reconciliation module's. set(load_config()) also contains system keys, the secrets-file keys load_config() merges in, and non-dict values; and any directory holding a manifest.json counted as installed even when that manifest does not parse. Either looseness clears a finding that is still true -- and a secrets key read as a plugin is the precise bug the filter exists to stop reporting, so reintroducing that asymmetry while re-checking was the wrong way round. The two extractions now live in state_reconciliation.py as config_plugin_ids() and disk_plugin_ids(), with ignored_config_keys() and secrets_top_level_keys() alongside. _get_config_state() and _get_disk_state() use them too, so there is one definition rather than two that can drift. _get_disk_state() re-reads each manifest for version/name after taking membership from the shared extractor; that costs one extra small read per plugin on a path that runs once per boot. Minor, the new test: the fixture assigned api_v3.config_manager and api_v3.plugin_manager directly. Those live on a module-level blueprint singleton, so the mocks leaked into every later test that imports api_v3 -- pointing at a tmp_path already deleted. Both now go through monkeypatch.setattr, which restores them. This is the same pollution class that made an earlier test in this session break seven unrelated ones, so it is worth getting right. Five cases added for the parity itself: a secrets key, a system key and a non-dict value must not clear an "installed but missing from config" finding, and neither an unparseable manifest nor a .standalone-backup- directory may count as installed. All five fail against the looser version. Linux CI on the preceding commit: Core unit tests, plugin harness, CodeQL and CodeRabbit all pass. Codacy reads action_required on every commit of this branch including the first, so it is pre-existing and not from this work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
428 lines
20 KiB
Python
428 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):
|
|
base = (Path(project_root) / 'web_interface/templates/v3/base.html'
|
|
).read_text(encoding='utf-8')
|
|
assert 'widgets/google-oauth.js' in base
|
|
|
|
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']
|