Files
LEDMatrix/test/test_api_v3_calendar_credentials.py
T
ChuckandClaude Opus 5.5 7ab6fb1aff refactor(web): read plugins through a PluginCatalog; only the display runs them (#688)
The web process built its own PluginManager and loaded plugins into itself:
store installs and updates loaded or reloaded a web-side copy, and config
saves and enable/disable called on_config_change, on_enable and on_disable
on it. None of that reached the panel, and /plugins/installed reported
runtime state from those copies.

- Add PluginCatalog (src/plugin_system/plugin_catalog.py): manifests,
  directories, display modes, installed version, schema and config reads,
  with no way to run a plugin. app.py and both blueprints use it; the
  plugin_manager blueprint attribute is gone.
- Remove every lifecycle call from the web routes. Config changes already
  reach the display through ConfigService (on_config_change) and the
  enabled-set reconcile.
- Health and metrics readers move to api_v3.health_tracker /
  resource_monitor. /plugins/installed reports loaded/state/error_info as
  null (the display does not publish them) and enabled by the display's
  rule.
- Store install, update and uninstall answer restart_required when the
  running display will not pick the change up by itself
  (display_restart_required). The restart banner follows the flag via
  window.noteRestartRequired instead of the /config/main URL heuristic;
  /config/main now sends restart_required: true.
- The one remaining in-process import of plugin code (Starlark helper
  modules, oauth_flow action scripts) goes through
  _import_plugin_code_in_web_process() until a web-entry contract.
- /plugins/installed reports vegas_participation (from #682) from the
  user's setting or the manifest, with vegas_participation_source; when
  only the plugin's code decides it, null with source 'runtime', since the
  web process no longer has plugin instances to ask.
- Check & Update All keeps its restart flags when the final list refresh
  fails, and asks for a restart when an enabled plugin's first request got
  no answer and the re-sent one found it up to date.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:39:44 -04:00

227 lines
9.9 KiB
Python

"""
Endpoint tests for POST /plugins/calendar/upload-credentials.
The endpoint takes an uploaded Google OAuth credentials file, writes it
into the calendar plugin's directory as credentials.json at mode 0600, and
copies any previous file aside first. It had no tests.
Regression coverage for two fixed bugs:
- The OAuth-shape check sat inside `except Exception: pass`, so a valid
JSON document that is not an object — a bare `42`, a list, a string —
raised TypeError on the membership test, was swallowed, and got saved
as credentials.json anyway.
- Each overwrite created a timestamped backup and nothing ever removed
them, so every re-upload left another complete copy of the user's OAuth
client credentials in the plugin directory, indefinitely.
"""
import io
import json
import os
import stat
import sys
import time
from pathlib import Path
from types import SimpleNamespace
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
URL = "/api/v3/plugins/calendar/upload-credentials"
VALID_CREDENTIALS = {
"installed": {
"client_id": "abc.apps.googleusercontent.com",
"client_secret": "shh",
"redirect_uris": ["http://localhost"],
}
}
@pytest.fixture
def plugin_dir(tmp_path, api_v3_module):
directory = tmp_path / "plugins" / "calendar"
directory.mkdir(parents=True)
api_v3_module.api_v3.plugin_catalog.get_plugin_directory.return_value = str(directory)
return directory
def upload(client, content, filename="credentials.json"):
# bytes are sent verbatim (to exercise malformed input); anything else
# is serialized, so None becomes the JSON literal null rather than an
# empty body.
payload = content if isinstance(content, bytes) else json.dumps(content).encode()
return client.post(
URL,
data={"file": (io.BytesIO(payload), filename)},
content_type="multipart/form-data",
)
def backups(plugin_dir):
return sorted(plugin_dir.glob("credentials.json.backup.*"))
class TestRequestValidation:
def test_no_file_part_is_a_400(self, api_v3_client, plugin_dir):
response = api_v3_client.post(URL, data={}, content_type="multipart/form-data")
assert response.status_code == 400
assert "No file provided" in response.get_json()["message"]
def test_empty_filename_is_a_400(self, api_v3_client, plugin_dir):
response = upload(api_v3_client, VALID_CREDENTIALS, filename="")
assert response.status_code == 400
@pytest.mark.parametrize("filename", ["creds.txt", "creds.pem", "creds"])
def test_non_json_extension_is_a_400(self, api_v3_client, plugin_dir, filename):
response = upload(api_v3_client, VALID_CREDENTIALS, filename=filename)
assert response.status_code == 400
assert "JSON file" in response.get_json()["message"]
def test_uppercase_json_extension_accepted(self, api_v3_client, plugin_dir):
assert upload(api_v3_client, VALID_CREDENTIALS,
filename="CREDENTIALS.JSON").status_code == 200
def test_oversized_file_is_a_400(self, api_v3_client, plugin_dir):
response = upload(api_v3_client, b"x" * (1024 * 1024 + 1))
assert response.status_code == 400
assert "1MB" in response.get_json()["message"]
assert not (plugin_dir / "credentials.json").exists()
def test_invalid_json_is_a_400(self, api_v3_client, plugin_dir):
response = upload(api_v3_client, b"{not json")
assert response.status_code == 400
assert "not valid JSON" in response.get_json()["message"]
assert not (plugin_dir / "credentials.json").exists()
def test_missing_plugin_directory_is_a_404(self, api_v3_client, api_v3_module, tmp_path):
api_v3_module.api_v3.plugin_catalog.get_plugin_directory.return_value = str(
tmp_path / "not-installed")
assert upload(api_v3_client, VALID_CREDENTIALS).status_code == 404
class TestOAuthShapeValidation:
def test_installed_key_accepted(self, api_v3_client, plugin_dir):
assert upload(api_v3_client, VALID_CREDENTIALS).status_code == 200
def test_web_key_accepted(self, api_v3_client, plugin_dir):
assert upload(api_v3_client, {"web": {"client_id": "x"}}).status_code == 200
def test_object_without_oauth_keys_is_a_400(self, api_v3_client, plugin_dir):
response = upload(api_v3_client, {"something": "else"})
assert response.status_code == 400
assert "valid Google OAuth" in response.get_json()["message"]
assert not (plugin_dir / "credentials.json").exists()
@pytest.mark.parametrize("content", [42, "a string", [1, 2, 3], True, None])
def test_valid_json_that_is_not_an_object_is_rejected(
self, api_v3_client, plugin_dir, content):
# Regression: `'installed' not in 42` raises TypeError, which the
# bare `except Exception: pass` swallowed — the file was then saved
# as credentials.json despite being unusable as credentials.
response = upload(api_v3_client, content)
assert response.status_code == 400
assert "valid Google OAuth" in response.get_json()["message"]
assert not (plugin_dir / "credentials.json").exists()
class TestSaving:
def test_file_written_with_contents_intact(self, api_v3_client, plugin_dir):
response = upload(api_v3_client, VALID_CREDENTIALS)
assert response.status_code == 200
saved = json.loads((plugin_dir / "credentials.json").read_text())
assert saved == VALID_CREDENTIALS
def test_response_reports_the_path(self, api_v3_client, plugin_dir):
body = upload(api_v3_client, VALID_CREDENTIALS).get_json()
assert body["path"].endswith("credentials.json")
def test_permissions_are_owner_only(self, api_v3_client, plugin_dir):
upload(api_v3_client, VALID_CREDENTIALS)
mode = stat.S_IMODE((plugin_dir / "credentials.json").stat().st_mode)
assert mode == 0o600
def test_first_upload_creates_no_backup(self, api_v3_client, plugin_dir):
upload(api_v3_client, VALID_CREDENTIALS)
assert backups(plugin_dir) == []
def test_overwrite_backs_up_the_previous_file(self, api_v3_client, plugin_dir):
(plugin_dir / "credentials.json").write_text(json.dumps({"installed": {"old": 1}}))
upload(api_v3_client, VALID_CREDENTIALS)
assert len(backups(plugin_dir)) == 1
assert json.loads(backups(plugin_dir)[0].read_text()) == {"installed": {"old": 1}}
assert json.loads((plugin_dir / "credentials.json").read_text()) == VALID_CREDENTIALS
class TestBackupPruning:
def _seed(self, plugin_dir, count):
"""Create `count` backups with distinct, increasing mtimes."""
now = int(time.time())
for i in range(count):
path = plugin_dir / f"credentials.json.backup.{now - (count - i) * 10}"
path.write_text(json.dumps({"installed": {"gen": i}}))
os.utime(path, (now - (count - i) * 10, now - (count - i) * 10))
def test_old_backups_are_pruned(self, api_v3_client, plugin_dir):
# Regression: nothing ever removed these, so a plugin directory
# accumulated one full copy of the user's OAuth credentials per
# re-upload, forever.
(plugin_dir / "credentials.json").write_text(json.dumps({"installed": {"cur": 1}}))
self._seed(plugin_dir, 7)
assert len(backups(plugin_dir)) == 7
upload(api_v3_client, VALID_CREDENTIALS)
assert len(backups(plugin_dir)) == 5
def test_the_newest_backups_are_the_ones_kept(self, api_v3_client, plugin_dir):
(plugin_dir / "credentials.json").write_text(json.dumps({"installed": {"cur": 1}}))
self._seed(plugin_dir, 7)
upload(api_v3_client, VALID_CREDENTIALS)
remaining = backups(plugin_dir)
# The just-created backup (of "cur") plus the four newest seeds.
contents = [json.loads(p.read_text()) for p in remaining]
assert {"installed": {"cur": 1}} in contents
assert {"installed": {"gen": 0}} not in contents # oldest seed gone
def test_under_the_limit_nothing_is_removed(self, api_v3_client, plugin_dir):
(plugin_dir / "credentials.json").write_text(json.dumps({"installed": {"cur": 1}}))
self._seed(plugin_dir, 2)
upload(api_v3_client, VALID_CREDENTIALS)
assert len(backups(plugin_dir)) == 3 # 2 seeded + 1 new
def test_repeated_uploads_stay_bounded(
self, api_v3_client, plugin_dir, api_v3_module, monkeypatch):
# The backup filename carries int(time.time()), so uploads inside
# the same second all write the same name and overwrite each other.
# Advance a fake clock a second per round — otherwise this never
# reaches six backups and the bound holds for the wrong reason.
clock = {"now": int(time.time())}
monkeypatch.setattr(
api_v3_module, "time", SimpleNamespace(time=lambda: clock["now"]))
for i in range(10):
clock["now"] += 1
upload(api_v3_client, {"installed": {"round": i}})
os.utime(plugin_dir / "credentials.json",
(clock["now"], clock["now"]))
remaining = backups(plugin_dir)
assert len(remaining) == 5
# And they are the five most recent rounds, not an arbitrary five.
kept = sorted(int(p.name.rsplit(".", 1)[1]) for p in remaining)
assert kept == [clock["now"] - 4 + i for i in range(5)]
def test_unremovable_backup_does_not_fail_the_upload(
self, api_v3_client, plugin_dir, monkeypatch):
(plugin_dir / "credentials.json").write_text(json.dumps({"installed": {"cur": 1}}))
self._seed(plugin_dir, 7)
def refuse(self):
raise OSError("read-only filesystem")
monkeypatch.setattr(Path, "unlink", refuse)
# Pruning is housekeeping; failing it must not lose the upload.
assert upload(api_v3_client, VALID_CREDENTIALS).status_code == 200