test(api): cover the credentials upload, and stop it hoarding secrets

The endpoint that receives the user's Google OAuth credentials file had
no tests. Two bugs surfaced.

The OAuth-shape check ran inside `except Exception: pass`. A JSON
document that parses but is not an object — a bare 42, true, null, a
list — makes `'installed' not in creds_data` raise TypeError, which the
bare except swallowed, and the file was then written out as
credentials.json regardless. The check now decides the outcome instead
of being advisory, so anything not credentials-shaped is refused up
front rather than failing later inside the calendar plugin.

Every overwrite copies the old file to credentials.json.backup.<ts> and
nothing removed them, so a user who re-uploaded ten times had ten
complete sets of OAuth client credentials sitting in the plugin
directory, indefinitely. Keep the newest five. Pruning is housekeeping,
so a backup that cannot be removed logs and leaves the upload alone.

27 tests: size and extension limits, malformed JSON, the shape check,
0600 permissions on the written file, backup-on-overwrite, and pruning
including the repeated-upload case that stays bounded.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
This commit is contained in:
Claude
2026-08-13 13:54:09 +00:00
parent 799733fb1d
commit 7cb42848fd
2 changed files with 252 additions and 9 deletions
+38 -9
View File
@@ -7239,6 +7239,29 @@ def serve_plugin_static(plugin_id, file_path):
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
_MAX_CREDENTIAL_BACKUPS = 5
def _prune_credential_backups(plugin_dir: Path) -> None:
"""Keep only the newest _MAX_CREDENTIAL_BACKUPS credential backups.
Every re-upload copies the previous credentials.json aside. Without
pruning those accumulate for the life of the install each one a
complete set of OAuth client credentials sitting in the plugin
directory.
"""
backups = sorted(
plugin_dir.glob('credentials.json.backup.*'),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
for stale in backups[_MAX_CREDENTIAL_BACKUPS:]:
try:
stale.unlink()
except OSError:
logger.warning("Could not remove old credential backup %s", stale.name)
@api_v3.route('/plugins/calendar/upload-credentials', methods=['POST'])
def upload_calendar_credentials():
"""Upload credentials.json file for calendar plugin"""
@@ -7270,20 +7293,25 @@ def upload_calendar_credentials():
except json.JSONDecodeError:
return jsonify({'status': 'error', 'message': 'File is not valid JSON'}), 400
# Validate it looks like Google OAuth credentials
# Validate it looks like Google OAuth credentials. The content
# already parsed as JSON above, so anything raising here means it is
# not credentials-shaped — a bare scalar, for instance, where the
# membership test raises TypeError. Reject rather than swallow: a
# file saved as credentials.json but not usable as credentials only
# fails later, somewhere less obvious.
try:
file.seek(0)
creds_data = json.loads(file.read())
file.seek(0)
# Check for required Google OAuth fields
if 'installed' not in creds_data and 'web' not in creds_data:
return jsonify({
'status': 'error',
'message': 'File does not appear to be a valid Google OAuth credentials file'
}), 400
is_oauth_shaped = 'installed' in creds_data or 'web' in creds_data
except Exception:
pass # Continue even if validation fails
is_oauth_shaped = False
if not is_oauth_shaped:
return jsonify({
'status': 'error',
'message': 'File does not appear to be a valid Google OAuth credentials file'
}), 400
# Get plugin directory
plugin_id = 'calendar'
@@ -7303,6 +7331,7 @@ def upload_calendar_credentials():
backup_path = Path(plugin_dir) / f'credentials.json.backup.{int(time.time())}'
import shutil
shutil.copy2(credentials_path, backup_path)
_prune_credential_backups(Path(plugin_dir))
# Save new file
file.save(str(credentials_path))