diff --git a/test/web_interface/test_calendar_oauth_endpoints.py b/test/web_interface/test_calendar_oauth_endpoints.py new file mode 100644 index 00000000..e7208cb8 --- /dev/null +++ b/test/web_interface/test_calendar_oauth_endpoints.py @@ -0,0 +1,212 @@ +"""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') + source = (Path(project_root) / 'web_interface/blueprints/api_v3.py').read_text(encoding='utf-8') + assert "'/plugins/calendar/list-calendars'" in source + assert "'/plugins/calendar/authenticate'" in source + + +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) + fake_build = lambda *a, **k: types.SimpleNamespace( + calendarList=lambda: types.SimpleNamespace( + list=lambda: types.SimpleNamespace( + execute=lambda: {'items': items}))) + + 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'] diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 6062b85d..5043d840 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -7312,6 +7312,186 @@ def upload_calendar_credentials(): logger.error('Error in upload_calendar_credentials', exc_info=True) return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +def _calendar_plugin_dir() -> Optional[Path]: + """Where the calendar plugin is installed, or None if it is not.""" + if api_v3.plugin_manager: + plugin_dir = api_v3.plugin_manager.get_plugin_directory('calendar') + else: + plugin_dir = PROJECT_ROOT / 'plugins' / 'calendar' + if not plugin_dir: + return None + plugin_dir = Path(plugin_dir) + return plugin_dir if plugin_dir.exists() else None + + +def _run_calendar_registration(plugin_dir: Path, stdin_payload: str): + """Run the plugin's OAuth script and return the JSON object it prints. + + The script decides between web and terminal mode by whether stdin is a + tty, so it must be given a pipe. It emits one JSON object on stdout; the + last parsable line is taken, because an import warning or a library's + stderr redirection can land in front of it. + + Returns (payload, error_message). Exactly one is None. + """ + script = plugin_dir / 'calendar_registration.py' + if not script.exists(): + return None, 'Authentication script not found in the calendar plugin' + + try: + result = subprocess.run( # nosec B603 - fixed script path inside the plugin dir + [sys.executable, str(script)], + input=stdin_payload, + capture_output=True, + text=True, + timeout=120, + cwd=str(plugin_dir), + ) + except subprocess.TimeoutExpired: + return None, 'Authentication timed out after 120s' + except OSError as e: + return None, 'Could not run the authentication script: %s' % e + + for line in reversed((result.stdout or '').splitlines()): + line = line.strip() + if not line: + continue + try: + payload = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(payload, dict): + return payload, None + + detail = (result.stderr or result.stdout or '').strip()[:300] + return None, 'Authentication script produced no result%s' % ( + ': %s' % detail if detail else '') + + +@api_v3.route('/plugins/calendar/authenticate', methods=['POST']) +def authenticate_calendar(): + """Google OAuth for the calendar plugin, in the two steps it requires. + + Step 1 (no body) returns the consent URL to open. Step 2 posts back the + URL Google redirected to -- it fails to load, because the redirect points + at a loopback address nothing is listening on, but the address bar carries + the authorization code -- and the script exchanges it for a token. + + Two calls rather than one because the user has to visit Google in between. + The script persists the PKCE verifier from step 1 for step 2 to reuse; the + exchange fails with "Missing code verifier" otherwise. + """ + try: + plugin_dir = _calendar_plugin_dir() + if plugin_dir is None: + return jsonify({ + 'status': 'error', + 'message': 'The calendar plugin is not installed' + }), 404 + + if not (plugin_dir / 'credentials.json').exists(): + return jsonify({ + 'status': 'error', + 'message': ('No credentials.json yet. Upload your Google OAuth ' + 'client file first (Step 1).') + }), 400 + + data = request.get_json(silent=True) or {} + redirect_url = (data.get('redirect_url') or data.get('code') or '').strip() + + payload, error = _run_calendar_registration(plugin_dir, redirect_url) + if error: + return jsonify({'status': 'error', 'message': error}), 500 + if payload.get('status') != 'success': + # The script's own diagnosis is more useful than anything that + # could be reconstructed here. + return jsonify(payload), 400 + return jsonify(payload) + + except Exception as e: + logger.error('Error in authenticate_calendar', exc_info=True) + return jsonify({'status': 'error', + 'message': 'An error occurred; see logs for details', + 'details': describe_exception(e)}), 500 + + +@api_v3.route('/plugins/calendar/list-calendars', methods=['GET']) +def list_calendar_calendars(): + """The calendars this account can see, for the config picker. + + Reads the token the OAuth flow wrote rather than shelling out again: the + picker is used interactively and a subprocess per click is slower than the + API call it would be wrapping. + """ + try: + plugin_dir = _calendar_plugin_dir() + if plugin_dir is None: + return jsonify({ + 'status': 'error', + 'message': 'The calendar plugin is not installed' + }), 404 + + token_file = plugin_dir / 'token.pickle' + if not token_file.exists(): + return jsonify({ + 'status': 'error', + 'message': ('Not authenticated with Google yet. Complete Step 2 ' + 'first, then load your calendars.') + }), 400 + + try: + import pickle + from google.auth.transport.requests import Request as GoogleRequest + from googleapiclient.discovery import build as build_google_service + except ImportError as e: + return jsonify({ + 'status': 'error', + 'message': ('The Google API libraries are not installed. Install ' + "the calendar plugin's requirements.txt. (%s)" % e) + }), 500 + + with open(token_file, 'rb') as handle: + # Written only by this plugin's own OAuth flow, into its own + # directory, and read here exactly as the plugin itself reads it. + creds = pickle.load(handle) # nosec B301 - locally generated token + + if creds and creds.expired and creds.refresh_token: + creds.refresh(GoogleRequest()) + with open(token_file, 'wb') as handle: + pickle.dump(creds, handle) + os.chmod(token_file, 0o600) + + if not creds or not creds.valid: + return jsonify({ + 'status': 'error', + 'message': ('Stored Google credentials are no longer valid. ' + 'Run Step 2 again to re-authenticate.') + }), 400 + + service = build_google_service('calendar', 'v3', credentials=creds) + entries = service.calendarList().list().execute().get('items', []) + + calendars = [{ + 'id': entry.get('id'), + # The picker labels each row with summary and falls back to the id + # only in its own display, so send something either way. + 'summary': entry.get('summary') or entry.get('id'), + 'primary': bool(entry.get('primary', False)), + } for entry in entries if entry.get('id')] + + # Primary first, then alphabetically: the list is usually short but the + # one the user wants is almost always their own calendar. + calendars.sort(key=lambda c: (not c['primary'], c['summary'].lower())) + + return jsonify({'status': 'success', 'calendars': calendars}) + + except Exception as e: + logger.error('Error in list_calendar_calendars', exc_info=True) + return jsonify({'status': 'error', + 'message': 'An error occurred; see logs for details', + 'details': describe_exception(e)}), 500 + + @api_v3.route('/plugins/assets/delete', methods=['POST']) def delete_plugin_asset(): """Delete an asset file for a plugin""" diff --git a/web_interface/static/v3/js/widgets/google-oauth.js b/web_interface/static/v3/js/widgets/google-oauth.js new file mode 100644 index 00000000..e288adb7 --- /dev/null +++ b/web_interface/static/v3/js/widgets/google-oauth.js @@ -0,0 +1,167 @@ +/** + * Google OAuth Widget + * + * Step 2 of the calendar plugin's setup, between uploading the OAuth client + * file and picking calendars. Google will not let a headless device complete + * consent on its own, so the flow is necessarily two calls with a human in + * between: + * + * 1. POST /api/v3/plugins/calendar/authenticate with no body + * -> { auth_url } to open in a browser + * 2. the browser lands on a loopback address that fails to load; its URL + * carries the authorization code. POST it back as redirect_url + * -> the server exchanges it and writes token.pickle + * + * The failed page in step 2 is expected and is worth saying out loud, because + * it looks exactly like something went wrong. + * + * @module GoogleOAuthWidget + */ + +(function () { + 'use strict'; + + if (typeof window.LEDMatrixWidgets === 'undefined') { + console.error('[GoogleOAuthWidget] LEDMatrixWidgets registry not found. Load registry.js first.'); + return; + } + + const ENDPOINT = '/api/v3/plugins/calendar/authenticate'; + + window.LEDMatrixWidgets.register('google-oauth', { + name: 'Google OAuth Widget', + version: '1.0.0', + + /** + * @param {HTMLElement} container + * @param {Object} config - schema config (unused) + * @param {*} value - unused; this widget stores nothing + * @param {Object} options - { fieldId, pluginId, name } + */ + render: function (container, config, value, options) { + const fieldId = options.fieldId; + + // Nothing is stored in config by this step -- the result is + // token.pickle on the device -- but the form still expects a field. + const hidden = document.createElement('input'); + hidden.type = 'hidden'; + hidden.id = fieldId + '_hidden'; + hidden.name = options.name; + hidden.value = value || ''; + + const startBtn = document.createElement('button'); + startBtn.type = 'button'; + startBtn.className = 'px-3 py-1.5 text-sm rounded-md bg-blue-600 hover:bg-blue-700 text-white'; + startBtn.innerHTML = ' Connect Google Account'; + + const status = document.createElement('p'); + status.className = 'text-xs text-gray-400 mt-2'; + + const step2 = document.createElement('div'); + step2.className = 'mt-3 hidden'; + + const link = document.createElement('a'); + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + link.className = 'text-blue-400 underline text-sm break-all'; + link.textContent = 'Open the Google consent screen'; + + const hint = document.createElement('p'); + hint.className = 'text-xs text-gray-400 mt-2'; + hint.textContent = + 'After approving, your browser will try to open a page that fails ' + + 'to load. That is expected. Copy its full address from the bar ' + + 'and paste it below.'; + + const codeInput = document.createElement('input'); + codeInput.type = 'text'; + codeInput.placeholder = 'http://127.0.0.1/?code=...'; + codeInput.className = + 'mt-2 block w-full px-3 py-2 text-sm border border-gray-600 ' + + 'rounded-md bg-gray-800 text-gray-100'; + + const finishBtn = document.createElement('button'); + finishBtn.type = 'button'; + finishBtn.className = 'mt-2 px-3 py-1.5 text-sm rounded-md bg-green-600 hover:bg-green-700 text-white'; + finishBtn.innerHTML = ' Finish Authentication'; + + function say(message, kind) { + status.textContent = message; + status.className = 'text-xs mt-2 ' + ( + kind === 'error' ? 'text-red-400' + : kind === 'success' ? 'text-green-400' + : 'text-gray-400'); + } + + function post(body) { + return fetch(ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body || {}) + }).then(function (r) { + return r.json().catch(function () { + // A non-JSON body here means the request never reached + // the handler -- worth saying so rather than "undefined". + return { status: 'error', message: 'Server returned ' + r.status }; + }); + }); + } + + startBtn.addEventListener('click', function () { + startBtn.disabled = true; + say('Requesting a consent link...'); + post({}).then(function (data) { + startBtn.disabled = false; + if (data.status !== 'success' || !data.auth_url) { + say(data.message || 'Could not start authentication.', 'error'); + return; + } + link.href = data.auth_url; + step2.classList.remove('hidden'); + say(data.message || 'Open the link, approve, then paste the address back.'); + }).catch(function (err) { + startBtn.disabled = false; + say('Request failed: ' + err.message, 'error'); + }); + }); + + finishBtn.addEventListener('click', function () { + const pasted = codeInput.value.trim(); + if (!pasted) { + say('Paste the address your browser was redirected to.', 'error'); + return; + } + finishBtn.disabled = true; + say('Exchanging the code with Google...'); + post({ redirect_url: pasted }).then(function (data) { + finishBtn.disabled = false; + if (data.status !== 'success') { + say(data.message || 'Authentication failed.', 'error'); + return; + } + say(data.message || 'Authenticated.', 'success'); + step2.classList.add('hidden'); + codeInput.value = ''; + }).catch(function (err) { + finishBtn.disabled = false; + say('Request failed: ' + err.message, 'error'); + }); + }); + + step2.appendChild(link); + step2.appendChild(hint); + step2.appendChild(codeInput); + step2.appendChild(finishBtn); + + container.appendChild(hidden); + container.appendChild(startBtn); + container.appendChild(status); + container.appendChild(step2); + }, + + getValue: function (fieldId) { + const hidden = document.getElementById(fieldId + '_hidden'); + return hidden ? hidden.value : ''; + } + }); +})(); diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index 28ed48b9..fb4cfa6a 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -987,6 +987,7 @@ +