- Introduced new image model picker component for selecting models and qualities. - Updated image model utilities to handle new models and quality options. - Implemented backend logic for validating image model options and handling requests. - Added tests for image model selection, quality handling, and API interactions. - Adjusted existing tests to accommodate new model features and ensure compatibility.
1304 lines
42 KiB
Python
Executable File
1304 lines
42 KiB
Python
Executable File
import asyncio
|
|
import io
|
|
import json
|
|
import logging
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import aiohttp
|
|
import pytest
|
|
|
|
from backend.tests.support.contract import (
|
|
PNG_BYTES,
|
|
_fake_gallery_entry,
|
|
client,
|
|
config,
|
|
gallery_queries_router,
|
|
settings_repo,
|
|
)
|
|
from backend.app.api.routers import gallery_batch
|
|
from backend.app.core import secrets
|
|
from backend.app.integrations.nodeimage import client as nodeimage_client
|
|
from backend.app.repositories.coordination import create_gallery_job, get_gallery_job
|
|
from backend.app.schemas.gallery import GalleryEntry
|
|
from backend.app.services import gallery_jobs
|
|
|
|
|
|
def _enable_nodeimage() -> None:
|
|
settings_repo.save_nodeimage_settings(
|
|
{
|
|
"enabled": True,
|
|
"api_key": "${TEST_NODEIMAGE_API_KEY}",
|
|
}
|
|
)
|
|
|
|
|
|
def _wait_for_nodeimage_job(test_client, job_id: str, timeout: float = 5.0):
|
|
deadline = time.time() + timeout
|
|
last = None
|
|
while time.time() < deadline:
|
|
response = test_client.get(
|
|
f"/api/gallery/nodeimage-upload-jobs/{job_id}"
|
|
)
|
|
assert response.status_code == 200
|
|
last = response.json()
|
|
if last["status"] in {
|
|
"success",
|
|
"partial_failure",
|
|
"cancelled",
|
|
"error",
|
|
}:
|
|
return last
|
|
time.sleep(0.05)
|
|
raise AssertionError(f"NodeImage job {job_id} did not finish: {last}")
|
|
|
|
|
|
def _start_nodeimage_batch(test_client, ids: list[str]):
|
|
response = test_client.post(
|
|
"/api/gallery/batch/nodeimage-upload",
|
|
json={"ids": ids},
|
|
)
|
|
assert response.status_code == 202
|
|
body = response.json()
|
|
assert body["status_url"].endswith(
|
|
f"/api/gallery/nodeimage-upload-jobs/{body['job_id']}"
|
|
)
|
|
assert body["events_url"].endswith(
|
|
f"/api/gallery/nodeimage-upload-jobs/{body['job_id']}/events"
|
|
)
|
|
assert body["cancel_url"].endswith(
|
|
f"/api/gallery/nodeimage-upload-jobs/{body['job_id']}/cancel"
|
|
)
|
|
return body
|
|
|
|
|
|
def test_single_nodeimage_upload_success_and_missing_entry(client, monkeypatch):
|
|
_fake_gallery_entry("node-single", "single", "1024x1024", "node-single.png")
|
|
_enable_nodeimage()
|
|
seen = {}
|
|
|
|
async def fake_upload(path, filename, effective):
|
|
seen.update(
|
|
path=path,
|
|
size=path.stat().st_size,
|
|
filename=filename,
|
|
api_key=effective.api_key,
|
|
)
|
|
return nodeimage_client.NodeImageUploadResult(
|
|
url="https://cdn.nodeimage.com/single.png",
|
|
markdown="",
|
|
)
|
|
|
|
monkeypatch.setattr(gallery_queries_router, "upload_image_file", fake_upload)
|
|
monkeypatch.setattr(
|
|
gallery_queries_router,
|
|
"_resolve_gallery_image_path",
|
|
lambda _filename: pytest.fail("single upload must not re-query gallery references"),
|
|
)
|
|
|
|
response = client.post("/api/gallery/node-single/nodeimage-upload")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {
|
|
"url": "https://cdn.nodeimage.com/single.png",
|
|
"markdown": "",
|
|
}
|
|
assert seen == {
|
|
"path": Path(config.IMAGES_DIR) / "node-single.png",
|
|
"size": len(PNG_BYTES),
|
|
"filename": "node-single.png",
|
|
"api_key": "nodeimage-api-key",
|
|
}
|
|
assert client.post("/api/gallery/missing/nodeimage-upload").status_code == 404
|
|
|
|
|
|
def test_single_nodeimage_upload_preserves_missing_and_unreadable_file_errors(
|
|
client,
|
|
monkeypatch,
|
|
):
|
|
missing = _fake_gallery_entry(
|
|
"node-missing-file",
|
|
"missing",
|
|
"1024x1024",
|
|
"node-missing-file.png",
|
|
)
|
|
unreadable = _fake_gallery_entry(
|
|
"node-unreadable-file",
|
|
"unreadable",
|
|
"1024x1024",
|
|
"node-unreadable-file.png",
|
|
)
|
|
_enable_nodeimage()
|
|
(Path(config.IMAGES_DIR) / missing.filename).unlink()
|
|
|
|
missing_response = client.post(
|
|
"/api/gallery/node-missing-file/nodeimage-upload"
|
|
)
|
|
assert missing_response.status_code == 404
|
|
assert missing_response.json()["detail"] == "Image file not found"
|
|
|
|
async def unreadable_upload(*_args):
|
|
raise OSError("permission denied")
|
|
|
|
monkeypatch.setattr(
|
|
gallery_queries_router,
|
|
"upload_image_file",
|
|
unreadable_upload,
|
|
)
|
|
unreadable_response = client.post(
|
|
"/api/gallery/node-unreadable-file/nodeimage-upload"
|
|
)
|
|
assert unreadable_response.status_code == 404
|
|
assert unreadable_response.json()["detail"] == "Image file could not be read"
|
|
|
|
|
|
def test_single_nodeimage_upload_rejects_missing_config_and_upstream_failure(
|
|
client,
|
|
monkeypatch,
|
|
):
|
|
_fake_gallery_entry("node-failure", "failure", "1024x1024", "node-failure.png")
|
|
|
|
disabled = client.post("/api/gallery/node-failure/nodeimage-upload")
|
|
assert disabled.status_code == 400
|
|
assert "disabled" in disabled.json()["detail"]
|
|
|
|
_enable_nodeimage()
|
|
|
|
async def fail_upload(*_args):
|
|
raise nodeimage_client.NodeImageUploadError("NodeImage quota exceeded")
|
|
|
|
monkeypatch.setattr(gallery_queries_router, "upload_image_file", fail_upload)
|
|
failed = client.post("/api/gallery/node-failure/nodeimage-upload")
|
|
assert failed.status_code == 502
|
|
assert failed.json()["detail"] == "NodeImage quota exceeded"
|
|
|
|
|
|
def test_batch_nodeimage_upload_reports_partial_results(client, monkeypatch, caplog):
|
|
_fake_gallery_entry("node-batch-1", "one", "1024x1024", "node-batch-1.png")
|
|
_fake_gallery_entry("node-batch-2", "two", "1024x1024", "node-batch-2.png")
|
|
_fake_gallery_entry("node-batch-3", "three", "1024x1024", "node-batch-3.png")
|
|
_enable_nodeimage()
|
|
|
|
async def fake_upload(_path, filename, _effective):
|
|
if filename == "node-batch-2.png":
|
|
raise nodeimage_client.NodeImageUploadError("upstream unavailable")
|
|
if filename == "node-batch-3.png":
|
|
raise RuntimeError("unexpected upstream shape")
|
|
return nodeimage_client.NodeImageUploadResult(
|
|
url=f"https://cdn.nodeimage.com/{filename}",
|
|
markdown=f"",
|
|
)
|
|
|
|
monkeypatch.setattr(gallery_jobs, "upload_image_file", fake_upload)
|
|
caplog.set_level(logging.ERROR, logger=gallery_jobs.__name__)
|
|
created = _start_nodeimage_batch(
|
|
client,
|
|
[
|
|
"node-batch-1",
|
|
"missing-node",
|
|
"node-batch-2",
|
|
"node-batch-3",
|
|
],
|
|
)
|
|
body = _wait_for_nodeimage_job(client, created["job_id"])
|
|
|
|
assert body["requested_count"] == 4
|
|
assert body["uploaded_count"] == 1
|
|
assert body["failed_count"] == 3
|
|
assert [item["image_id"] for item in body["results"]] == [
|
|
"node-batch-1",
|
|
"missing-node",
|
|
"node-batch-2",
|
|
"node-batch-3",
|
|
]
|
|
assert body["results"][0]["status"] == "ok"
|
|
assert body["results"][0]["filename"] == "node-batch-1.png"
|
|
assert body["results"][1]["error"] == "Gallery entry not found"
|
|
assert body["results"][1]["filename"] is None
|
|
assert body["results"][2]["error"] == "upstream unavailable"
|
|
assert body["results"][2]["filename"] == "node-batch-2.png"
|
|
assert body["results"][3]["error"] == "Unexpected upload failure"
|
|
assert body["results"][3]["filename"] == "node-batch-3.png"
|
|
assert "unexpected upstream shape" not in json.dumps(body)
|
|
unexpected_record = next(
|
|
record
|
|
for record in caplog.records
|
|
if "Unexpected NodeImage upload failure" in record.getMessage()
|
|
)
|
|
assert unexpected_record.exc_info is not None
|
|
|
|
|
|
def test_batch_nodeimage_auth_probe_stops_uploads_and_preserves_file_errors(
|
|
client,
|
|
monkeypatch,
|
|
):
|
|
missing = _fake_gallery_entry(
|
|
"node-missing-file",
|
|
"missing file",
|
|
"1024x1024",
|
|
"node-missing-file.png",
|
|
)
|
|
_fake_gallery_entry(
|
|
"node-auth-probe",
|
|
"auth probe",
|
|
"1024x1024",
|
|
"node-auth-probe.png",
|
|
)
|
|
_fake_gallery_entry(
|
|
"node-unreadable",
|
|
"unreadable",
|
|
"1024x1024",
|
|
"node-unreadable.png",
|
|
)
|
|
_fake_gallery_entry(
|
|
"node-not-attempted",
|
|
"not attempted",
|
|
"1024x1024",
|
|
"node-not-attempted.png",
|
|
)
|
|
(Path(config.IMAGES_DIR) / missing.filename).unlink()
|
|
_enable_nodeimage()
|
|
|
|
calls = []
|
|
|
|
async def reject_upload(_path, filename, _effective):
|
|
calls.append(filename)
|
|
raise nodeimage_client.NodeImageAuthError(
|
|
"NodeImage API key was rejected."
|
|
)
|
|
|
|
unreadable_path = Path(config.IMAGES_DIR) / "node-unreadable.png"
|
|
unreadable_path.unlink()
|
|
unreadable_path.mkdir()
|
|
|
|
def fail_read_bytes(_path):
|
|
raise AssertionError("NodeImage batch upload must not read the whole file")
|
|
|
|
monkeypatch.setattr(gallery_jobs, "upload_image_file", reject_upload)
|
|
monkeypatch.setattr(Path, "read_bytes", fail_read_bytes)
|
|
|
|
created = _start_nodeimage_batch(
|
|
client,
|
|
[
|
|
"node-missing-file",
|
|
"missing-node",
|
|
"node-auth-probe",
|
|
"node-unreadable",
|
|
"node-not-attempted",
|
|
],
|
|
)
|
|
body = _wait_for_nodeimage_job(client, created["job_id"])
|
|
assert calls == ["node-auth-probe.png"]
|
|
assert body["requested_count"] == 5
|
|
assert body["uploaded_count"] == 0
|
|
assert body["failed_count"] == 5
|
|
assert [item["image_id"] for item in body["results"]] == [
|
|
"node-missing-file",
|
|
"missing-node",
|
|
"node-auth-probe",
|
|
"node-unreadable",
|
|
"node-not-attempted",
|
|
]
|
|
assert [item["error"] for item in body["results"]] == [
|
|
"Image file not found",
|
|
"Gallery entry not found",
|
|
"NodeImage API key was rejected.",
|
|
"Image file could not be read",
|
|
"NodeImage API key was rejected.",
|
|
]
|
|
assert [item["filename"] for item in body["results"]] == [
|
|
"node-missing-file.png",
|
|
None,
|
|
"node-auth-probe.png",
|
|
"node-unreadable.png",
|
|
"node-not-attempted.png",
|
|
]
|
|
|
|
|
|
def test_batch_nodeimage_probe_then_limits_concurrency(client, monkeypatch):
|
|
ids = [f"node-concurrent-{index}" for index in range(7)]
|
|
for image_id in ids:
|
|
_fake_gallery_entry(
|
|
image_id,
|
|
image_id,
|
|
"1024x1024",
|
|
f"{image_id}.png",
|
|
)
|
|
_enable_nodeimage()
|
|
|
|
active = 0
|
|
max_active = 0
|
|
calls = []
|
|
|
|
async def fake_upload(_path, filename, _effective):
|
|
nonlocal active, max_active
|
|
calls.append(filename)
|
|
active += 1
|
|
max_active = max(max_active, active)
|
|
await asyncio.sleep(0.01)
|
|
active -= 1
|
|
return nodeimage_client.NodeImageUploadResult(
|
|
url=f"https://cdn.nodeimage.com/{filename}",
|
|
markdown=f"",
|
|
)
|
|
|
|
config.NODEIMAGE_UPLOAD_CONCURRENCY = 4
|
|
monkeypatch.setattr(gallery_jobs, "upload_image_file", fake_upload)
|
|
created = _start_nodeimage_batch(client, ids)
|
|
body = _wait_for_nodeimage_job(client, created["job_id"])
|
|
assert calls[0] == "node-concurrent-0.png"
|
|
assert len(calls) == 7
|
|
assert max_active == 4
|
|
assert body["uploaded_count"] == 7
|
|
assert body["failed_count"] == 0
|
|
assert body["requested_count"] == body["uploaded_count"] + body["failed_count"]
|
|
|
|
|
|
def test_batch_nodeimage_stops_queued_uploads_after_late_auth_failure(
|
|
client,
|
|
monkeypatch,
|
|
):
|
|
ids = [f"node-late-auth-{index}" for index in range(7)]
|
|
for image_id in ids:
|
|
_fake_gallery_entry(
|
|
image_id,
|
|
image_id,
|
|
"1024x1024",
|
|
f"{image_id}.png",
|
|
)
|
|
_enable_nodeimage()
|
|
|
|
calls = []
|
|
|
|
async def fake_upload(_path, filename, _effective):
|
|
calls.append(filename)
|
|
if filename == "node-late-auth-0.png":
|
|
return nodeimage_client.NodeImageUploadResult(
|
|
url="https://cdn.nodeimage.com/probe.png",
|
|
markdown="",
|
|
)
|
|
if filename == "node-late-auth-1.png":
|
|
await asyncio.sleep(0.01)
|
|
raise nodeimage_client.NodeImageAuthError(
|
|
"NodeImage API key was rejected."
|
|
)
|
|
await asyncio.sleep(0.02)
|
|
return nodeimage_client.NodeImageUploadResult(
|
|
url=f"https://cdn.nodeimage.com/{filename}",
|
|
markdown=f"",
|
|
)
|
|
|
|
config.NODEIMAGE_UPLOAD_CONCURRENCY = 4
|
|
monkeypatch.setattr(gallery_jobs, "upload_image_file", fake_upload)
|
|
created = _start_nodeimage_batch(client, ids)
|
|
body = _wait_for_nodeimage_job(client, created["job_id"])
|
|
assert calls[0] == "node-late-auth-0.png"
|
|
assert set(calls) == {
|
|
f"node-late-auth-{index}.png" for index in range(5)
|
|
}
|
|
assert body["uploaded_count"] == 4
|
|
assert body["failed_count"] == 3
|
|
assert [item["error"] for item in body["results"][5:]] == [
|
|
"NodeImage API key was rejected.",
|
|
"NodeImage API key was rejected.",
|
|
]
|
|
|
|
|
|
def test_batch_nodeimage_upload_accepts_filtered_selection_token(client, monkeypatch):
|
|
_fake_gallery_entry(
|
|
"node-token-1",
|
|
"nodeimage token match one",
|
|
"1024x1024",
|
|
"node-token-1.png",
|
|
)
|
|
_fake_gallery_entry(
|
|
"node-token-2",
|
|
"outside selection",
|
|
"1024x1024",
|
|
"node-token-2.png",
|
|
)
|
|
_fake_gallery_entry(
|
|
"node-token-3",
|
|
"nodeimage token match three",
|
|
"1024x1024",
|
|
"node-token-3.png",
|
|
)
|
|
_enable_nodeimage()
|
|
|
|
async def fake_upload(_path, filename, _effective):
|
|
return nodeimage_client.NodeImageUploadResult(
|
|
url=f"https://cdn.nodeimage.com/{filename}",
|
|
markdown=f"",
|
|
)
|
|
|
|
monkeypatch.setattr(gallery_jobs, "upload_image_file", fake_upload)
|
|
token_response = client.post(
|
|
"/api/gallery/batch/selection-tokens",
|
|
json={"filters": {"prompt": "nodeimage token match"}},
|
|
)
|
|
assert token_response.status_code == 201
|
|
assert token_response.json()["count"] == 2
|
|
|
|
created = client.post(
|
|
"/api/gallery/batch/nodeimage-upload",
|
|
json={"selection_token": token_response.json()["selection_token"]},
|
|
)
|
|
assert created.status_code == 202
|
|
body = _wait_for_nodeimage_job(client, created.json()["job_id"])
|
|
assert body["requested_count"] == 2
|
|
assert body["uploaded_count"] == 2
|
|
assert body["failed_count"] == 0
|
|
assert {item["image_id"] for item in body["results"]} == {
|
|
"node-token-1",
|
|
"node-token-3",
|
|
}
|
|
|
|
|
|
def test_batch_nodeimage_upload_rejects_selection_token_over_limit(
|
|
client,
|
|
monkeypatch,
|
|
):
|
|
for index in range(3):
|
|
_fake_gallery_entry(
|
|
f"node-token-limit-{index}",
|
|
"nodeimage token over limit",
|
|
"1024x1024",
|
|
f"node-token-limit-{index}.png",
|
|
)
|
|
|
|
monkeypatch.setattr(gallery_batch, "NODEIMAGE_BATCH_MAX", 2)
|
|
token_response = client.post(
|
|
"/api/gallery/batch/selection-tokens",
|
|
json={"filters": {"prompt": "nodeimage token over limit"}},
|
|
)
|
|
assert token_response.status_code == 201
|
|
assert token_response.json()["count"] == 3
|
|
|
|
response = client.post(
|
|
"/api/gallery/batch/nodeimage-upload",
|
|
json={"selection_token": token_response.json()["selection_token"]},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
assert "at most 2 images" in response.json()["detail"]
|
|
assert "selected 3" in response.json()["detail"]
|
|
|
|
|
|
def test_nodeimage_batch_rejects_oversized_files_before_upload(client, monkeypatch):
|
|
entry = _fake_gallery_entry(
|
|
"node-oversized",
|
|
"oversized",
|
|
"1024x1024",
|
|
"node-oversized.png",
|
|
)
|
|
_enable_nodeimage()
|
|
config.MAX_FILE_SIZE_MB = 1
|
|
oversized_path = Path(config.IMAGES_DIR) / entry.filename
|
|
oversized_path.write_bytes(b"x" * (config.MAX_FILE_SIZE_MB * 1024 * 1024 + 1))
|
|
calls = []
|
|
|
|
async def fail_upload(_path, filename, _effective):
|
|
calls.append(filename)
|
|
raise AssertionError("oversized files must fail before upload")
|
|
|
|
monkeypatch.setattr(gallery_jobs, "upload_image_file", fail_upload)
|
|
created = _start_nodeimage_batch(client, [entry.id])
|
|
body = _wait_for_nodeimage_job(client, created["job_id"])
|
|
|
|
assert calls == []
|
|
assert body["status"] == "partial_failure"
|
|
assert body["failed_count"] == 1
|
|
assert body["results"][0]["error"] == "Image file is too large. Max size is 1 MB"
|
|
|
|
|
|
def test_nodeimage_batch_cancellation_preserves_inflight_results(client, monkeypatch):
|
|
ids = [f"node-cancel-{index}" for index in range(3)]
|
|
for image_id in ids:
|
|
_fake_gallery_entry(image_id, image_id, "1024x1024", f"{image_id}.png")
|
|
_enable_nodeimage()
|
|
config.NODEIMAGE_UPLOAD_CONCURRENCY = 1
|
|
started = threading.Event()
|
|
release = threading.Event()
|
|
calls = []
|
|
|
|
async def slow_upload(_path, filename, _effective):
|
|
calls.append(filename)
|
|
started.set()
|
|
await asyncio.to_thread(release.wait, 3)
|
|
return nodeimage_client.NodeImageUploadResult(
|
|
url=f"https://cdn.nodeimage.com/{filename}",
|
|
markdown=f"",
|
|
)
|
|
|
|
monkeypatch.setattr(gallery_jobs, "upload_image_file", slow_upload)
|
|
created = _start_nodeimage_batch(client, ids)
|
|
assert started.wait(2)
|
|
|
|
cancel_response = client.delete(
|
|
f"/api/gallery/nodeimage-upload-jobs/{created['job_id']}"
|
|
)
|
|
assert cancel_response.status_code == 200
|
|
assert cancel_response.json()["stage"] == "cancelling"
|
|
release.set()
|
|
|
|
body = _wait_for_nodeimage_job(client, created["job_id"])
|
|
assert body["status"] == "cancelled"
|
|
assert body["uploaded_count"] == 1
|
|
assert body["failed_count"] == 0
|
|
assert body["cancelled_count"] == 2
|
|
assert [item["status"] for item in body["results"]] == [
|
|
"ok",
|
|
"cancelled",
|
|
"cancelled",
|
|
]
|
|
assert calls == [f"{ids[0]}.png"]
|
|
|
|
|
|
def test_nodeimage_batch_capacity_is_reserved_before_dispatch(client, monkeypatch):
|
|
ids = ["node-capacity-0", "node-capacity-1"]
|
|
for image_id in ids:
|
|
_fake_gallery_entry(image_id, image_id, "1024x1024", f"{image_id}.png")
|
|
_enable_nodeimage()
|
|
started = threading.Event()
|
|
release = threading.Event()
|
|
|
|
async def slow_upload(_path, filename, _effective):
|
|
started.set()
|
|
await asyncio.to_thread(release.wait, 3)
|
|
return nodeimage_client.NodeImageUploadResult(
|
|
url=f"https://cdn.nodeimage.com/{filename}",
|
|
markdown=f"",
|
|
)
|
|
|
|
monkeypatch.setattr(gallery_jobs, "upload_image_file", slow_upload)
|
|
created = _start_nodeimage_batch(client, ids)
|
|
assert started.wait(2)
|
|
|
|
rejected = client.post(
|
|
"/api/gallery/batch/nodeimage-upload",
|
|
json={"ids": [ids[0]]},
|
|
)
|
|
assert rejected.status_code == 429
|
|
assert "active NodeImage upload jobs" in rejected.json()["detail"]
|
|
release.set()
|
|
_wait_for_nodeimage_job(client, created["job_id"])
|
|
|
|
|
|
def test_nodeimage_batch_sse_replays_persisted_terminal_results(client, monkeypatch):
|
|
_fake_gallery_entry("node-sse", "sse", "1024x1024", "node-sse.png")
|
|
_enable_nodeimage()
|
|
|
|
async def fake_upload(_path, filename, _effective):
|
|
return nodeimage_client.NodeImageUploadResult(
|
|
url=f"https://cdn.nodeimage.com/{filename}",
|
|
markdown=f"",
|
|
)
|
|
|
|
monkeypatch.setattr(gallery_jobs, "upload_image_file", fake_upload)
|
|
created = _start_nodeimage_batch(client, ["node-sse"])
|
|
body = _wait_for_nodeimage_job(client, created["job_id"])
|
|
assert body["status"] == "success"
|
|
|
|
events = client.get(body["events_url"])
|
|
assert events.status_code == 200
|
|
assert "event: nodeimage_upload" in events.text
|
|
assert '"status":"success"' in events.text
|
|
assert "https://cdn.nodeimage.com/node-sse.png" in events.text
|
|
|
|
|
|
def test_nodeimage_batch_recovery_skips_persisted_completed_items(client, monkeypatch):
|
|
ids = ["node-recovery-0", "node-recovery-1"]
|
|
for image_id in ids:
|
|
_fake_gallery_entry(image_id, image_id, "1024x1024", f"{image_id}.png")
|
|
_enable_nodeimage()
|
|
calls = []
|
|
first_result = gallery_jobs._nodeimage_result_item(
|
|
ids[0],
|
|
f"{ids[0]}.png",
|
|
"ok",
|
|
url="https://cdn.nodeimage.com/recovered.png",
|
|
markdown="",
|
|
)
|
|
job = gallery_jobs._build_nodeimage_upload_job(ids, 2, [])
|
|
job.update(
|
|
status="running",
|
|
stage="uploading",
|
|
lease_owner="recovery-worker",
|
|
lease_expires_at=gallery_jobs._gallery_job_lease_expires_at(),
|
|
payload={"ids": ids, "results": [first_result], "cancel_requested": False},
|
|
)
|
|
stored_job = create_gallery_job(**job)
|
|
|
|
async def fake_upload(_path, filename, _effective):
|
|
calls.append(filename)
|
|
return nodeimage_client.NodeImageUploadResult(
|
|
url=f"https://cdn.nodeimage.com/{filename}",
|
|
markdown=f"",
|
|
)
|
|
|
|
monkeypatch.setattr(gallery_jobs, "upload_image_file", fake_upload)
|
|
asyncio.run(gallery_jobs._run_nodeimage_upload_job(stored_job))
|
|
recovered = get_gallery_job("nodeimage_upload", stored_job["job_id"])
|
|
|
|
assert recovered is not None
|
|
assert recovered["status"] == "success"
|
|
assert calls == [f"{ids[1]}.png"]
|
|
assert [item["image_id"] for item in recovered["payload"]["results"]] == ids
|
|
|
|
|
|
def test_nodeimage_settings_round_trip_and_secret_origin_binding(client):
|
|
current = client.get("/api/settings").json()
|
|
assert current["nodeimage"]["enabled"] is False
|
|
assert current["nodeimage"]["has_api_key"] is False
|
|
assert current["nodeimage"]["api_key_resolvable"] is False
|
|
|
|
payload = {
|
|
"active_preset_id": current["active_preset_id"],
|
|
"preset_name": "Primary",
|
|
"api_url": current["api_url"],
|
|
"api_key": None,
|
|
"api_path": current["api_path"],
|
|
"default_model": current["default_model"],
|
|
"nodeimage": {
|
|
"enabled": True,
|
|
"api_key": "${TEST_NODEIMAGE_API_KEY}",
|
|
},
|
|
}
|
|
updated = client.post("/api/settings", json=payload)
|
|
assert updated.status_code == 200
|
|
nodeimage = updated.json()["nodeimage"]
|
|
assert nodeimage == {
|
|
"enabled": True,
|
|
"api_key_masked": "${TEST_NODEIMAGE_API_KEY}",
|
|
"has_api_key": True,
|
|
"api_key_resolvable": True,
|
|
"api_key_source": "env",
|
|
"api_key_env_var": "TEST_NODEIMAGE_API_KEY",
|
|
"api_key_secret_id": None,
|
|
}
|
|
assert settings_repo.load_nodeimage_settings()["api_key"] == "${TEST_NODEIMAGE_API_KEY}"
|
|
|
|
secrets.configure_registry(
|
|
json.dumps(
|
|
{
|
|
"nodeimage-wrong-origin": {
|
|
"purpose": "nodeimage_api_key",
|
|
"origin": "https://example.com",
|
|
"env": "TEST_NODEIMAGE_API_KEY",
|
|
}
|
|
}
|
|
)
|
|
)
|
|
payload["nodeimage"]["api_key"] = "nodeimage-wrong-origin"
|
|
rejected = client.post("/api/settings", json=payload)
|
|
assert rejected.status_code == 422
|
|
assert "not bound to the target origin" in rejected.json()["detail"]
|
|
|
|
|
|
def test_nodeimage_settings_reports_whether_saved_key_can_be_resolved(
|
|
client,
|
|
monkeypatch,
|
|
request,
|
|
):
|
|
settings_repo.save_nodeimage_settings(
|
|
{
|
|
"enabled": True,
|
|
"api_key": "${UNSET_NODEIMAGE_API_KEY}",
|
|
}
|
|
)
|
|
monkeypatch.delenv("UNSET_NODEIMAGE_API_KEY", raising=False)
|
|
|
|
unresolved = client.get("/api/settings")
|
|
assert unresolved.status_code == 200
|
|
assert unresolved.json()["nodeimage"] == {
|
|
"enabled": True,
|
|
"api_key_masked": "${UNSET_NODEIMAGE_API_KEY}",
|
|
"has_api_key": True,
|
|
"api_key_resolvable": False,
|
|
"api_key_source": "env",
|
|
"api_key_env_var": "UNSET_NODEIMAGE_API_KEY",
|
|
"api_key_secret_id": None,
|
|
}
|
|
|
|
monkeypatch.setenv("UNSET_NODEIMAGE_API_KEY", "resolved-nodeimage-key")
|
|
resolved = client.get("/api/settings")
|
|
assert resolved.status_code == 200
|
|
assert resolved.json()["nodeimage"]["api_key_resolvable"] is True
|
|
|
|
request.addfinalizer(lambda: secrets.configure_registry(""))
|
|
secrets.configure_registry(
|
|
json.dumps(
|
|
{
|
|
"nodeimage-invalid-binding": {
|
|
"purpose": "nodeimage_api_key",
|
|
"origin": "https://example.com",
|
|
"env": "UNSET_NODEIMAGE_API_KEY",
|
|
}
|
|
}
|
|
)
|
|
)
|
|
settings_repo.save_nodeimage_settings(
|
|
{
|
|
"enabled": True,
|
|
"api_key": "nodeimage-invalid-binding",
|
|
}
|
|
)
|
|
invalid_binding = client.get("/api/settings")
|
|
assert invalid_binding.status_code == 200
|
|
assert invalid_binding.json()["nodeimage"]["has_api_key"] is True
|
|
assert invalid_binding.json()["nodeimage"]["api_key_source"] == "registry"
|
|
assert invalid_binding.json()["nodeimage"]["api_key_resolvable"] is False
|
|
secrets.configure_registry("")
|
|
|
|
|
|
def test_nodeimage_settings_plaintext_path_is_opt_in_and_masked(client):
|
|
current = client.get("/api/settings").json()
|
|
payload = {
|
|
"active_preset_id": current["active_preset_id"],
|
|
"preset_name": "Primary",
|
|
"api_url": current["api_url"],
|
|
"api_key": None,
|
|
"api_path": current["api_path"],
|
|
"default_model": current["default_model"],
|
|
"nodeimage": {"enabled": True, "api_key": "plain-nodeimage-key"},
|
|
}
|
|
|
|
rejected = client.post("/api/settings", json=payload)
|
|
assert rejected.status_code == 422
|
|
assert "NodeImage API key must use ${ENV_VAR_NAME}" in rejected.text
|
|
|
|
config.ALLOW_PLAINTEXT_SECRETS = True
|
|
accepted = client.post("/api/settings", json=payload)
|
|
assert accepted.status_code == 200
|
|
nodeimage = accepted.json()["nodeimage"]
|
|
assert nodeimage["has_api_key"] is True
|
|
assert nodeimage["api_key_source"] == "stored"
|
|
assert nodeimage["api_key_masked"] == "plai***-key"
|
|
assert nodeimage["api_key_secret_id"] is None
|
|
assert settings_repo.load_nodeimage_settings()["api_key"] == "plain-nodeimage-key"
|
|
|
|
|
|
class _ResponseContent:
|
|
def __init__(self, payload):
|
|
self.payload = (
|
|
payload
|
|
if isinstance(payload, (bytes, Exception))
|
|
else json.dumps(payload).encode("utf-8")
|
|
)
|
|
|
|
async def iter_chunked(self, _size):
|
|
if isinstance(self.payload, Exception):
|
|
raise self.payload
|
|
yield self.payload
|
|
|
|
|
|
class _Response:
|
|
def __init__(self, status: int, payload):
|
|
self.status = status
|
|
self.content = _ResponseContent(payload)
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *_args):
|
|
return False
|
|
|
|
|
|
class _Session:
|
|
def __init__(self, responses):
|
|
self.responses = list(responses)
|
|
self.calls = []
|
|
|
|
def post(self, url, **kwargs):
|
|
self.calls.append((url, kwargs))
|
|
response = self.responses.pop(0)
|
|
if isinstance(response, Exception):
|
|
raise response
|
|
return response
|
|
|
|
|
|
class _Pool:
|
|
def __init__(self, session):
|
|
self.session = session
|
|
|
|
def get(self, **_kwargs):
|
|
return self.session
|
|
|
|
|
|
class _TrackingFile(io.BytesIO):
|
|
pass
|
|
|
|
|
|
def test_nodeimage_client_reopens_and_closes_streaming_source_on_retry(monkeypatch):
|
|
effective = nodeimage_client.NodeImageEffectiveSettings(True, "test-key")
|
|
session = _Session(
|
|
[
|
|
_Response(503, {"success": False, "error": "temporary failure"}),
|
|
_Response(
|
|
200,
|
|
{
|
|
"success": True,
|
|
"links": {
|
|
"direct": "https://cdn.nodeimage.com/stream.png",
|
|
"markdown": "",
|
|
},
|
|
},
|
|
),
|
|
]
|
|
)
|
|
opened = []
|
|
|
|
def source():
|
|
handle = _TrackingFile(PNG_BYTES)
|
|
opened.append(handle)
|
|
return handle
|
|
|
|
monkeypatch.setattr(nodeimage_client, "get_pool", lambda: _Pool(session))
|
|
|
|
async def no_sleep(_delay):
|
|
return None
|
|
|
|
monkeypatch.setattr(nodeimage_client.asyncio, "sleep", no_sleep)
|
|
result = asyncio.run(
|
|
nodeimage_client.upload_image_source(source, "stream.png", effective)
|
|
)
|
|
|
|
assert result.url.endswith("stream.png")
|
|
assert len(opened) == 2
|
|
assert all(handle.closed for handle in opened)
|
|
assert all(
|
|
getattr(call[1]["data"], "_fields", [({}, {}, handle)])[0][2] is handle
|
|
for call, handle in zip(session.calls, opened)
|
|
)
|
|
|
|
|
|
def test_nodeimage_client_retries_5xx_once_and_does_not_retry_auth(monkeypatch):
|
|
effective = nodeimage_client.NodeImageEffectiveSettings(True, "test-key")
|
|
session = _Session(
|
|
[
|
|
_Response(503, {"success": False, "error": "temporary failure"}),
|
|
_Response(
|
|
200,
|
|
{
|
|
"success": True,
|
|
"links": {
|
|
"direct": "https://cdn.nodeimage.com/retry.png",
|
|
"markdown": "",
|
|
},
|
|
},
|
|
),
|
|
]
|
|
)
|
|
monkeypatch.setattr(nodeimage_client, "get_pool", lambda: _Pool(session))
|
|
async def no_sleep(_delay):
|
|
return None
|
|
|
|
monkeypatch.setattr(nodeimage_client.asyncio, "sleep", no_sleep)
|
|
|
|
result = asyncio.run(
|
|
nodeimage_client.upload_image_bytes(PNG_BYTES, "retry.png", effective)
|
|
)
|
|
assert result.url.endswith("retry.png")
|
|
assert len(session.calls) == 2
|
|
assert session.calls[0][1]["headers"] == {"X-API-Key": "test-key"}
|
|
|
|
auth_session = _Session(
|
|
[_Response(401, {"success": False, "error": "invalid api key"})]
|
|
)
|
|
monkeypatch.setattr(nodeimage_client, "get_pool", lambda: _Pool(auth_session))
|
|
with pytest.raises(nodeimage_client.NodeImageAuthError):
|
|
asyncio.run(
|
|
nodeimage_client.upload_image_bytes(PNG_BYTES, "auth.png", effective)
|
|
)
|
|
assert len(auth_session.calls) == 1
|
|
|
|
non_json_auth_session = _Session([_Response(403, b"Forbidden")])
|
|
monkeypatch.setattr(
|
|
nodeimage_client,
|
|
"get_pool",
|
|
lambda: _Pool(non_json_auth_session),
|
|
)
|
|
with pytest.raises(nodeimage_client.NodeImageAuthError):
|
|
asyncio.run(
|
|
nodeimage_client.upload_image_bytes(PNG_BYTES, "auth.png", effective)
|
|
)
|
|
assert len(non_json_auth_session.calls) == 1
|
|
|
|
text_auth_session = _Session(
|
|
[_Response(400, {"success": False, "error": "unauthorized"})]
|
|
)
|
|
monkeypatch.setattr(
|
|
nodeimage_client,
|
|
"get_pool",
|
|
lambda: _Pool(text_auth_session),
|
|
)
|
|
with pytest.raises(nodeimage_client.NodeImageAuthError):
|
|
asyncio.run(
|
|
nodeimage_client.upload_image_bytes(PNG_BYTES, "auth.png", effective)
|
|
)
|
|
assert len(text_auth_session.calls) == 1
|
|
|
|
invalid_key_session = _Session(
|
|
[_Response(400, {"success": False, "error": "invalid api key"})]
|
|
)
|
|
monkeypatch.setattr(
|
|
nodeimage_client,
|
|
"get_pool",
|
|
lambda: _Pool(invalid_key_session),
|
|
)
|
|
with pytest.raises(nodeimage_client.NodeImageAuthError):
|
|
asyncio.run(
|
|
nodeimage_client.upload_image_bytes(PNG_BYTES, "auth.png", effective)
|
|
)
|
|
assert len(invalid_key_session.calls) == 1
|
|
|
|
|
|
def test_nodeimage_client_retries_5xx_with_auth_text(monkeypatch):
|
|
effective = nodeimage_client.NodeImageEffectiveSettings(True, "test-key")
|
|
session = _Session(
|
|
[
|
|
_Response(503, {"success": False, "error": "unauthorized"}),
|
|
_Response(
|
|
200,
|
|
{
|
|
"success": True,
|
|
"links": {
|
|
"direct": "https://cdn.nodeimage.com/retry-auth-text.png",
|
|
"markdown": "",
|
|
},
|
|
},
|
|
),
|
|
]
|
|
)
|
|
monkeypatch.setattr(nodeimage_client, "get_pool", lambda: _Pool(session))
|
|
|
|
async def no_sleep(_delay):
|
|
return None
|
|
|
|
monkeypatch.setattr(nodeimage_client.asyncio, "sleep", no_sleep)
|
|
|
|
result = asyncio.run(
|
|
nodeimage_client.upload_image_bytes(
|
|
PNG_BYTES,
|
|
"retry-auth-text.png",
|
|
effective,
|
|
)
|
|
)
|
|
assert result.url.endswith("retry-auth-text.png")
|
|
assert len(session.calls) == 2
|
|
|
|
|
|
def test_nodeimage_client_retries_connect_error_once(monkeypatch):
|
|
effective = nodeimage_client.NodeImageEffectiveSettings(True, "test-key")
|
|
session = _Session(
|
|
[
|
|
aiohttp.ClientConnectorError(None, OSError("temporary")),
|
|
_Response(
|
|
200,
|
|
{
|
|
"success": True,
|
|
"links": {
|
|
"direct": "https://cdn.nodeimage.com/network.png",
|
|
"markdown": "",
|
|
},
|
|
},
|
|
),
|
|
]
|
|
)
|
|
monkeypatch.setattr(nodeimage_client, "get_pool", lambda: _Pool(session))
|
|
async def no_sleep(_delay):
|
|
return None
|
|
|
|
monkeypatch.setattr(nodeimage_client.asyncio, "sleep", no_sleep)
|
|
|
|
result = asyncio.run(
|
|
nodeimage_client.upload_image_bytes(PNG_BYTES, "network.png", effective)
|
|
)
|
|
assert result.url.endswith("network.png")
|
|
assert len(session.calls) == 2
|
|
|
|
|
|
def test_nodeimage_client_retries_unreadable_5xx_response_once(monkeypatch):
|
|
effective = nodeimage_client.NodeImageEffectiveSettings(True, "test-key")
|
|
session = _Session(
|
|
[
|
|
_Response(503, aiohttp.ClientConnectionError("response interrupted")),
|
|
_Response(
|
|
200,
|
|
{
|
|
"success": True,
|
|
"links": {
|
|
"direct": "https://cdn.nodeimage.com/recovered.png",
|
|
},
|
|
},
|
|
),
|
|
]
|
|
)
|
|
monkeypatch.setattr(nodeimage_client, "get_pool", lambda: _Pool(session))
|
|
|
|
async def no_sleep(_delay):
|
|
return None
|
|
|
|
monkeypatch.setattr(nodeimage_client.asyncio, "sleep", no_sleep)
|
|
result = asyncio.run(
|
|
nodeimage_client.upload_image_bytes(PNG_BYTES, "recovered.png", effective)
|
|
)
|
|
|
|
assert result.url.endswith("recovered.png")
|
|
assert len(session.calls) == 2
|
|
|
|
|
|
def test_nodeimage_client_generates_markdown_from_validated_direct_url(monkeypatch):
|
|
effective = nodeimage_client.NodeImageEffectiveSettings(True, "test-key")
|
|
direct = "https://cdn.nodeimage.com/image(1).png?caption=]()<>"
|
|
session = _Session(
|
|
[
|
|
_Response(
|
|
200,
|
|
{
|
|
"success": True,
|
|
"links": {
|
|
"direct": direct,
|
|
"markdown": ")",
|
|
},
|
|
},
|
|
)
|
|
]
|
|
)
|
|
monkeypatch.setattr(nodeimage_client, "get_pool", lambda: _Pool(session))
|
|
|
|
result = asyncio.run(
|
|
nodeimage_client.upload_image_bytes(
|
|
PNG_BYTES,
|
|
"../bad](*).png",
|
|
effective,
|
|
)
|
|
)
|
|
|
|
assert result.url == direct
|
|
assert result.markdown == (
|
|
r"![bad\]\(\*\).png](https://cdn.nodeimage.com/"
|
|
r"image%281%29.png?caption=]%28%29%3C%3E)"
|
|
)
|
|
assert "javascript" not in result.markdown
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("filename", "expected_content_type"),
|
|
[
|
|
("image.webp", "image/webp"),
|
|
("image.avif", "image/avif"),
|
|
("image.heif", "image/heif"),
|
|
("image.unknown-extension", "application/octet-stream"),
|
|
],
|
|
)
|
|
def test_nodeimage_client_uses_stable_content_types(
|
|
monkeypatch,
|
|
filename,
|
|
expected_content_type,
|
|
):
|
|
effective = nodeimage_client.NodeImageEffectiveSettings(True, "test-key")
|
|
session = _Session(
|
|
[
|
|
_Response(
|
|
200,
|
|
{
|
|
"success": True,
|
|
"links": {"direct": f"https://cdn.nodeimage.com/{filename}"},
|
|
},
|
|
)
|
|
]
|
|
)
|
|
monkeypatch.setattr(nodeimage_client, "get_pool", lambda: _Pool(session))
|
|
|
|
asyncio.run(nodeimage_client.upload_image_bytes(PNG_BYTES, filename, effective))
|
|
|
|
form = session.calls[0][1]["data"]
|
|
assert form._fields[0][1]["Content-Type"] == expected_content_type
|
|
|
|
|
|
def test_nodeimage_file_inspection_returns_path_or_structured_error(
|
|
tmp_path,
|
|
monkeypatch,
|
|
):
|
|
entry = GalleryEntry(
|
|
id="inspection",
|
|
prompt="inspection",
|
|
size="1x1",
|
|
filename="inspection.png",
|
|
created_at="2026-01-01T00:00:00Z",
|
|
)
|
|
|
|
monkeypatch.setattr(gallery_jobs, "safe_image_path", lambda _filename: None)
|
|
missing = gallery_jobs._inspect_nodeimage_file(entry)
|
|
assert isinstance(missing, dict)
|
|
assert missing["error"] == "Image file not found"
|
|
|
|
missing_path = tmp_path / "missing.png"
|
|
monkeypatch.setattr(
|
|
gallery_jobs,
|
|
"safe_image_path",
|
|
lambda _filename: missing_path,
|
|
)
|
|
assert gallery_jobs._inspect_nodeimage_file(entry)["error"] == "Image file not found"
|
|
|
|
empty_path = tmp_path / "empty.png"
|
|
empty_path.write_bytes(b"")
|
|
monkeypatch.setattr(
|
|
gallery_jobs,
|
|
"safe_image_path",
|
|
lambda _filename: empty_path,
|
|
)
|
|
assert gallery_jobs._inspect_nodeimage_file(entry)["error"] == "Image file is empty"
|
|
|
|
class UnreadablePath:
|
|
def stat(self):
|
|
return SimpleNamespace(st_size=1)
|
|
|
|
def open(self, _mode):
|
|
raise OSError("unreadable")
|
|
|
|
monkeypatch.setattr(
|
|
gallery_jobs,
|
|
"safe_image_path",
|
|
lambda _filename: UnreadablePath(),
|
|
)
|
|
unreadable = gallery_jobs._inspect_nodeimage_file(entry)
|
|
assert unreadable["error"] == "Image file could not be read"
|
|
|
|
valid_path = tmp_path / "valid.png"
|
|
valid_path.write_bytes(PNG_BYTES)
|
|
original_limit = config.MAX_FILE_SIZE_MB
|
|
monkeypatch.setattr(config, "MAX_FILE_SIZE_MB", 0)
|
|
monkeypatch.setattr(
|
|
gallery_jobs,
|
|
"safe_image_path",
|
|
lambda _filename: valid_path,
|
|
)
|
|
oversized = gallery_jobs._inspect_nodeimage_file(entry)
|
|
assert "too large" in oversized["error"]
|
|
|
|
monkeypatch.setattr(config, "MAX_FILE_SIZE_MB", original_limit)
|
|
assert gallery_jobs._inspect_nodeimage_file(entry) == valid_path
|
|
|
|
|
|
def test_nodeimage_client_does_not_retry_ambiguous_network_errors(monkeypatch):
|
|
effective = nodeimage_client.NodeImageEffectiveSettings(True, "test-key")
|
|
|
|
async def no_sleep(_delay):
|
|
raise AssertionError("ambiguous network failures must not be retried")
|
|
|
|
monkeypatch.setattr(nodeimage_client.asyncio, "sleep", no_sleep)
|
|
|
|
client_error_session = _Session([aiohttp.ClientConnectionError("temporary")])
|
|
monkeypatch.setattr(
|
|
nodeimage_client,
|
|
"get_pool",
|
|
lambda: _Pool(client_error_session),
|
|
)
|
|
with pytest.raises(
|
|
nodeimage_client.NodeImageUploadError,
|
|
match="upload request failed",
|
|
):
|
|
asyncio.run(
|
|
nodeimage_client.upload_image_bytes(PNG_BYTES, "client-error.png", effective)
|
|
)
|
|
assert len(client_error_session.calls) == 1
|
|
|
|
timeout_session = _Session([_Response(200, asyncio.TimeoutError())])
|
|
monkeypatch.setattr(
|
|
nodeimage_client,
|
|
"get_pool",
|
|
lambda: _Pool(timeout_session),
|
|
)
|
|
with pytest.raises(
|
|
nodeimage_client.NodeImageUploadError,
|
|
match="unreadable response",
|
|
):
|
|
asyncio.run(
|
|
nodeimage_client.upload_image_bytes(PNG_BYTES, "timeout.png", effective)
|
|
)
|
|
assert len(timeout_session.calls) == 1
|
|
|
|
|
|
def test_nodeimage_client_rejects_unsafe_direct_link(monkeypatch):
|
|
effective = nodeimage_client.NodeImageEffectiveSettings(True, "test-key")
|
|
session = _Session(
|
|
[
|
|
_Response(
|
|
200,
|
|
{
|
|
"success": True,
|
|
"links": {
|
|
"direct": "javascript:alert(1)",
|
|
"markdown": ")",
|
|
},
|
|
},
|
|
)
|
|
]
|
|
)
|
|
monkeypatch.setattr(nodeimage_client, "get_pool", lambda: _Pool(session))
|
|
|
|
with pytest.raises(
|
|
nodeimage_client.NodeImageUploadError,
|
|
match="invalid direct link",
|
|
):
|
|
asyncio.run(
|
|
nodeimage_client.upload_image_bytes(PNG_BYTES, "unsafe.png", effective)
|
|
)
|
|
|
|
|
|
def test_nodeimage_client_redacts_effective_key_from_upstream_error(monkeypatch):
|
|
effective = nodeimage_client.NodeImageEffectiveSettings(
|
|
True,
|
|
"literal-nodeimage-key",
|
|
)
|
|
session = _Session(
|
|
[
|
|
_Response(
|
|
400,
|
|
{
|
|
"success": False,
|
|
"error": "Rejected literal-nodeimage-key",
|
|
},
|
|
)
|
|
]
|
|
)
|
|
monkeypatch.setattr(nodeimage_client, "get_pool", lambda: _Pool(session))
|
|
|
|
with pytest.raises(nodeimage_client.NodeImageUploadError) as exc_info:
|
|
asyncio.run(
|
|
nodeimage_client.upload_image_bytes(PNG_BYTES, "error.png", effective)
|
|
)
|
|
assert "literal-nodeimage-key" not in str(exc_info.value)
|
|
assert "[REDACTED]" in str(exc_info.value)
|
|
|
|
|
|
def test_nodeimage_client_redacts_key_before_truncating_error(monkeypatch):
|
|
api_key = "boundary-secret-value"
|
|
effective = nodeimage_client.NodeImageEffectiveSettings(True, api_key)
|
|
session = _Session(
|
|
[
|
|
_Response(
|
|
400,
|
|
{
|
|
"success": False,
|
|
"error": f"{'x' * 495}{api_key}",
|
|
},
|
|
)
|
|
]
|
|
)
|
|
monkeypatch.setattr(nodeimage_client, "get_pool", lambda: _Pool(session))
|
|
|
|
with pytest.raises(nodeimage_client.NodeImageUploadError) as exc_info:
|
|
asyncio.run(
|
|
nodeimage_client.upload_image_bytes(PNG_BYTES, "error.png", effective)
|
|
)
|
|
|
|
message = str(exc_info.value)
|
|
assert len(message) == 500
|
|
assert api_key not in message
|
|
assert api_key[:5] not in message
|