Big audit + fix release driven by two full-codebase Gemini Pro reviews and first-party verification against the live ElevenLabs API spec. Security - Scrub API keys from every raised error (URL params, JSON detail, conn errors). Defense against leaking into ComfyUI logs / workflow PNGs. - AccountInfo redacts PII (email, name, billing IDs) by default and now uses /v1/user/subscription (no PII). - Download size cap (500MB default) — was unbounded BytesIO. - Multipart file pointers reset between retries (was sending empty body on attempt 2). - SaveAudio sanitizes filename + subdir against ../ traversal. Fixed — Credit-burning bugs - IS_CHANGED=NaN removed from paid nodes; replaced with InputCacheMixin that hashes (inputs minus api_key) so identical re-queues don't re-bill. - VoiceClone / VoiceCreate require explicit create=True (was uploading on every queue, burning voice slots). Fixed — API contract bugs (silent failures vs current spec) - Music: uses music_length_ms (was duration_seconds), 600s max (was 300), exposes composition_plan, respect_sections_durations, store_for_inpainting, sign_with_c2pa, model_id=music_v1. - SFX: model_id=eleven_text_to_sound_v2 (was eleven_sfx_v2 — invalid). - TTS: forces apply_text_normalization=off for eleven_v3 (only valid value per spec); raises early if turbo/flash_v2_5 lacks language_code. - STT: actually sends timestamps_granularity=none when chosen. - TTS: malformed pronunciation_dictionary_locators raises instead of silently dropping. Fixed — Audio I/O - PCM decode pads odd byte count so truncated streams don't ValueError. - audio_to_comfy returns 1s silence on empty body (was crashing). - Fallback silence is 1s @ 44.1kHz, not [1,1,1] (downstream STFT was asserting window_size). - comfy_audio_to_bytes accepts 1-D / 2-D / 3-D waveforms. - NamedTemporaryFile replaced with BytesIO (Windows handle leak + reopen). - parse_error_response sniffs Content-Type, returns <binary response> for non-text bodies (was UnicodeDecodeError on binary 500s). Fixed — Retry hardening - Retry-After honors HTTP-date format (RFC 7231). - Exponential backoff clamped to 60s + ±25% jitter. - Voice cache bounded LRU (32 entries) — was unbounded growth. - Pagination hard-cap 100 pages (belt-and-braces). Added — 11 new utility nodes GetVoiceByName, VoiceTagInserter, SubtitleExport (SRT/VTT), AudioConcat (resample + broadcast), AudioNormalize (peak/RMS dBFS), AudioMetadata, AudioChannels (mono/stereo/strip), AudioTrim, CostEstimator, VoiceSettingsPreset, SaveAudio (sanitized). Added — Quality-of-life - enable_logging toggle (zero-retention mode) on TTS/TTSTimestamps/ STS/Dialogue. - FetchVoices: gender filter + count output. - VoiceDesign: returns all 3 previews (was throwing away the 2 you paid for); preview_index selector. - Dialogue: validates total text length, explicit model dropdown, defaults normalization=off (required for v3). - STT: adds scribe_v1. - SFX: live duration validation (0.5-30s). - previous_text/next_text tooltips clarified (context, not output). Tests: 101/101 passing (was 0). All HTTP mocked — no real API calls. - test_audio_conversions.py (13) — AUDIO dict shape, PCM round-trip - test_errors_scrubbing.py (15) — secret-leakage regression, parser - test_retry.py (18) — backoff, file-pointer reset, size cap - test_pagination.py (7) — termination + LRU bound - test_nodes.py (48) — node validation, credit safety, utility nodes Node count: 15 -> 26. Audit notes in CHANGELOG.md.
116 lines
3.4 KiB
Python
116 lines
3.4 KiB
Python
"""Secret-leakage regression + error parsing tests."""
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from shared.errors import (
|
|
APIError,
|
|
APIPermanentError,
|
|
APITransientError,
|
|
APIQuotaError,
|
|
parse_error_response,
|
|
scrub_secrets,
|
|
)
|
|
|
|
|
|
# --------- scrub_secrets ---------
|
|
|
|
def test_scrub_secrets_redacts_explicit_token():
|
|
msg = "ConnectionError: failed to reach api with key sk_abc1234567890xyz"
|
|
out = scrub_secrets(msg, extra_secrets=["sk_abc1234567890xyz"])
|
|
assert "sk_abc1234567890xyz" not in out
|
|
assert "***REDACTED***" in out
|
|
|
|
|
|
def test_scrub_secrets_redacts_url_query_param():
|
|
msg = "GET https://api.example.com/v1/voices?xi-api-key=SECRETabcdef1234567890 -> 401"
|
|
out = scrub_secrets(msg)
|
|
assert "SECRETabcdef1234567890" not in out
|
|
assert "REDACTED" in out
|
|
|
|
|
|
def test_scrub_secrets_url_pattern_with_token_param():
|
|
msg = "url=https://x.com/y?token=abc1234567890123456&z=1"
|
|
out = scrub_secrets(msg)
|
|
assert "abc1234567890123456" not in out
|
|
|
|
|
|
def test_scrub_secrets_handles_empty():
|
|
assert scrub_secrets("") == ""
|
|
assert scrub_secrets(None) is None
|
|
|
|
|
|
def test_scrub_secrets_ignores_short_secrets():
|
|
msg = "Used key=AAAA in test"
|
|
out = scrub_secrets(msg, extra_secrets=["AAAA"])
|
|
assert "AAAA" in out
|
|
|
|
|
|
# --------- parse_error_response ---------
|
|
|
|
def test_parse_error_response_json_detail_message():
|
|
body = json.dumps({"detail": {"message": "Quota exceeded"}})
|
|
err = parse_error_response("ElevenLabs", 402, body)
|
|
assert isinstance(err, APIQuotaError)
|
|
assert "Quota exceeded" in err.detail
|
|
|
|
|
|
def test_parse_error_response_json_detail_string():
|
|
body = json.dumps({"detail": "Bad voice id"})
|
|
err = parse_error_response("ElevenLabs", 422, body)
|
|
assert isinstance(err, APIPermanentError)
|
|
assert err.detail == "Bad voice id"
|
|
|
|
|
|
def test_parse_error_response_429_is_transient():
|
|
body = json.dumps({"detail": "Too many requests"})
|
|
err = parse_error_response("ElevenLabs", 429, body)
|
|
assert isinstance(err, APITransientError)
|
|
|
|
|
|
def test_parse_error_response_500_is_transient():
|
|
err = parse_error_response("ElevenLabs", 502, "Bad Gateway")
|
|
assert isinstance(err, APITransientError)
|
|
|
|
|
|
def test_parse_error_response_secret_scrubbed():
|
|
key = "sk_abc123def456ghi789jkl012"
|
|
body = json.dumps({"detail": "Invalid key " + key})
|
|
err = parse_error_response("ElevenLabs", 401, body, extra_secrets=[key])
|
|
assert key not in err.detail
|
|
assert "REDACTED" in err.detail
|
|
assert key not in str(err)
|
|
|
|
|
|
def test_parse_error_response_non_json_body_truncated():
|
|
body = "x" * 10000
|
|
err = parse_error_response("ElevenLabs", 500, body)
|
|
assert len(err.detail) <= 510
|
|
|
|
|
|
def test_parse_error_response_handles_none_body():
|
|
err = parse_error_response("ElevenLabs", 500, "")
|
|
assert isinstance(err, APITransientError)
|
|
|
|
|
|
def test_parse_error_response_handles_binary_via_response_obj():
|
|
class FakeResp:
|
|
headers = {"Content-Type": "audio/mpeg"}
|
|
text = "garbage-binary-text"
|
|
err = parse_error_response("ElevenLabs", 500, FakeResp())
|
|
assert "binary response" in err.detail.lower() or err.detail == "<binary response>"
|
|
|
|
|
|
# --------- APIError class ---------
|
|
|
|
def test_api_error_str_contains_service_and_status():
|
|
err = APIError("X", 404, "not found")
|
|
assert "[X]" in str(err)
|
|
assert "404" in str(err)
|
|
assert "not found" in str(err)
|
|
|
|
|
|
def test_quota_error_inherits_permanent():
|
|
e = APIQuotaError("X", 402, "out of credits")
|
|
assert isinstance(e, APIPermanentError)
|