chore: import upstream snapshot with attribution
docs / deploy (push) Has been cancelled
docs / changes (push) Has been cancelled
docs / check-and-build (push) Has been cancelled
build container image / cpu (push) Has been cancelled
build container image / cuda (push) Has been cancelled
build container image / rocm (push) Has been cancelled
frontend checks / frontend-checks (push) Has been cancelled
frontend tests / frontend-tests (push) Has been cancelled
lfs checks / lfs-check (push) Has been cancelled
python checks / python-checks (push) Has been cancelled
python tests / py3.12: macos-default (push) Has been cancelled
python tests / py3.11: windows-cpu (push) Has been cancelled
python tests / py3.12: windows-cpu (push) Has been cancelled
python tests / py3.11: linux-cpu (push) Has been cancelled
typegen checks / typegen-checks (push) Has been cancelled
uv lock checks / uv-lock-checks (push) Has been cancelled
openapi checks / openapi-checks (push) Has been cancelled
python tests / py3.11: macos-default (push) Has been cancelled
python tests / py3.12: linux-cpu (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:22:06 +08:00
commit cddb07a176
3370 changed files with 685519 additions and 0 deletions
View File
View File
+168
View File
@@ -0,0 +1,168 @@
"""Tests for SlidingWindowTokenMiddleware and token refresh behavior."""
from datetime import timedelta
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from invokeai.app.services.auth.token_service import TokenData, create_access_token, set_jwt_secret
@pytest.fixture(autouse=True)
def _setup_jwt_secret():
"""Ensure JWT secret is set for all tests."""
set_jwt_secret("test-secret-key-for-sliding-window-tests")
def _create_test_app() -> FastAPI:
"""Create a minimal FastAPI app with the SlidingWindowTokenMiddleware."""
from invokeai.app.api_app import SlidingWindowTokenMiddleware
test_app = FastAPI()
test_app.add_middleware(SlidingWindowTokenMiddleware)
@test_app.get("/test")
async def get_endpoint():
return {"ok": True}
@test_app.post("/test")
async def post_endpoint():
return {"ok": True}
@test_app.put("/test")
async def put_endpoint():
return {"ok": True}
@test_app.delete("/test")
async def delete_endpoint():
return {"ok": True}
return test_app
def _make_token(remember_me: bool = False, expires_delta: timedelta | None = None) -> str:
"""Create a test token."""
token_data = TokenData(
user_id="test-user",
email="test@test.com",
is_admin=False,
remember_me=remember_me,
)
return create_access_token(token_data, expires_delta)
class TestSlidingWindowTokenMiddleware:
"""Tests for SlidingWindowTokenMiddleware."""
def test_mutating_request_returns_refreshed_token(self):
"""Authenticated POST/PUT/PATCH/DELETE requests return X-Refreshed-Token."""
app = _create_test_app()
client = TestClient(app)
token = _make_token()
for method in ["post", "put", "delete"]:
response = getattr(client, method)("/test", headers={"Authorization": f"Bearer {token}"})
assert response.status_code == 200
assert "X-Refreshed-Token" in response.headers, f"{method.upper()} should return refreshed token"
def test_get_request_does_not_return_refreshed_token(self):
"""Authenticated GET requests do NOT return X-Refreshed-Token."""
app = _create_test_app()
client = TestClient(app)
token = _make_token()
response = client.get("/test", headers={"Authorization": f"Bearer {token}"})
assert response.status_code == 200
assert "X-Refreshed-Token" not in response.headers
def test_unauthenticated_request_does_not_return_refreshed_token(self):
"""Requests without a token do NOT return X-Refreshed-Token."""
app = _create_test_app()
client = TestClient(app)
response = client.post("/test")
assert response.status_code == 200
assert "X-Refreshed-Token" not in response.headers
def test_remember_me_token_refreshes_to_remember_me_duration(self):
"""A remember_me=True token refreshes with the remember-me duration, not the normal duration."""
from jose import jwt
from invokeai.app.api.routers.auth import TOKEN_EXPIRATION_REMEMBER_ME
from invokeai.app.services.auth.token_service import ALGORITHM, get_jwt_secret
app = _create_test_app()
client = TestClient(app)
# Create a remember-me token with only 1 hour remaining (less than 24h)
token = _make_token(remember_me=True, expires_delta=timedelta(hours=1))
response = client.post("/test", headers={"Authorization": f"Bearer {token}"})
assert "X-Refreshed-Token" in response.headers
# Decode the refreshed token and check its expiry
refreshed_token = response.headers["X-Refreshed-Token"]
payload = jwt.decode(refreshed_token, get_jwt_secret(), algorithms=[ALGORITHM])
# The refreshed token should have ~7 days of remaining life, not ~1 day
from datetime import datetime, timezone
remaining_seconds = payload["exp"] - datetime.now(timezone.utc).timestamp()
remaining_days = remaining_seconds / 86400
# Should be close to TOKEN_EXPIRATION_REMEMBER_ME (7 days), not TOKEN_EXPIRATION_NORMAL (1 day)
assert remaining_days > TOKEN_EXPIRATION_REMEMBER_ME - 0.1, (
f"Remember-me token was downgraded: {remaining_days:.1f} days remaining, "
f"expected ~{TOKEN_EXPIRATION_REMEMBER_ME}"
)
def test_normal_token_refreshes_to_normal_duration(self):
"""A remember_me=False token refreshes with the normal duration."""
from jose import jwt
from invokeai.app.api.routers.auth import TOKEN_EXPIRATION_NORMAL
from invokeai.app.services.auth.token_service import ALGORITHM, get_jwt_secret
app = _create_test_app()
client = TestClient(app)
token = _make_token(remember_me=False)
response = client.post("/test", headers={"Authorization": f"Bearer {token}"})
refreshed_token = response.headers["X-Refreshed-Token"]
payload = jwt.decode(refreshed_token, get_jwt_secret(), algorithms=[ALGORITHM])
from datetime import datetime, timezone
remaining_seconds = payload["exp"] - datetime.now(timezone.utc).timestamp()
remaining_days = remaining_seconds / 86400
# Should be close to TOKEN_EXPIRATION_NORMAL (1 day), not TOKEN_EXPIRATION_REMEMBER_ME (7 days)
assert remaining_days < TOKEN_EXPIRATION_NORMAL + 0.1, (
f"Normal token got remember-me duration: {remaining_days:.1f} days"
)
assert remaining_days > TOKEN_EXPIRATION_NORMAL - 0.1, (
f"Normal token duration too short: {remaining_days:.1f} days"
)
def test_remember_me_claim_preserved_in_refreshed_token(self):
"""The remember_me claim is preserved when a token is refreshed."""
from invokeai.app.services.auth.token_service import verify_token
app = _create_test_app()
client = TestClient(app)
# Test with remember_me=True
token = _make_token(remember_me=True)
response = client.post("/test", headers={"Authorization": f"Bearer {token}"})
refreshed_data = verify_token(response.headers["X-Refreshed-Token"])
assert refreshed_data is not None
assert refreshed_data.remember_me is True
# Test with remember_me=False
token = _make_token(remember_me=False)
response = client.post("/test", headers={"Authorization": f"Bearer {token}"})
refreshed_data = verify_token(response.headers["X-Refreshed-Token"])
assert refreshed_data is not None
assert refreshed_data.remember_me is False
+144
View File
@@ -0,0 +1,144 @@
import pytest
from invokeai.app.invocations.anima_denoise import (
ANIMA_SHIFT,
AnimaDenoiseInvocation,
inverse_loglinear_timestep_shift,
loglinear_timestep_shift,
)
class TestLoglinearTimestepShift:
"""Test the log-linear timestep shift function used for Anima's noise schedule."""
def test_shift_1_is_identity(self):
"""With alpha=1.0, shift should be identity."""
for t in [0.0, 0.25, 0.5, 0.75, 1.0]:
assert loglinear_timestep_shift(1.0, t) == t
def test_shift_at_zero(self):
"""At t=0, shifted sigma should be 0 regardless of alpha."""
assert loglinear_timestep_shift(3.0, 0.0) == 0.0
def test_shift_at_one(self):
"""At t=1, shifted sigma should be 1 regardless of alpha."""
assert loglinear_timestep_shift(3.0, 1.0) == pytest.approx(1.0)
def test_shift_3_increases_sigma(self):
"""With alpha=3.0, sigma should be larger than t (spends more time at high noise)."""
for t in [0.1, 0.25, 0.5, 0.75, 0.9]:
sigma = loglinear_timestep_shift(3.0, t)
assert sigma > t, f"At t={t}, sigma={sigma} should be > t"
def test_shift_monotonic(self):
"""Shifted sigmas should be monotonically increasing with t."""
prev = 0.0
for i in range(1, 101):
t = i / 100.0
sigma = loglinear_timestep_shift(3.0, t)
assert sigma > prev, f"Not monotonic at t={t}"
prev = sigma
def test_known_value(self):
"""Test a known value: at t=0.5, alpha=3.0, sigma = 3*0.5 / (1 + 2*0.5) = 0.75."""
assert loglinear_timestep_shift(3.0, 0.5) == pytest.approx(0.75)
class TestInverseLoglinearTimestepShift:
"""Test the inverse log-linear timestep shift (used for inpainting mask correction)."""
def test_inverse_shift_1_is_identity(self):
"""With alpha=1.0, inverse should be identity."""
for sigma in [0.0, 0.25, 0.5, 0.75, 1.0]:
assert inverse_loglinear_timestep_shift(1.0, sigma) == sigma
def test_roundtrip(self):
"""shift(inverse(sigma)) should recover sigma, and inverse(shift(t)) should recover t."""
for t in [0.0, 0.1, 0.25, 0.5, 0.75, 0.9, 1.0]:
sigma = loglinear_timestep_shift(3.0, t)
recovered_t = inverse_loglinear_timestep_shift(3.0, sigma)
assert recovered_t == pytest.approx(t, abs=1e-7), (
f"Roundtrip failed: t={t} -> sigma={sigma} -> recovered_t={recovered_t}"
)
def test_known_value(self):
"""At sigma=0.75, alpha=3.0, t should be 0.5 (inverse of the known shift value)."""
assert inverse_loglinear_timestep_shift(3.0, 0.75) == pytest.approx(0.5)
class TestGetSigmas:
"""Test the sigma schedule generation."""
def test_schedule_length(self):
"""Schedule should have num_steps + 1 entries."""
inv = AnimaDenoiseInvocation(
positive_conditioning=None, # type: ignore
transformer=None, # type: ignore
)
sigmas = inv._get_sigmas(30)
assert len(sigmas) == 31
def test_schedule_endpoints(self):
"""Schedule should start near 1.0 and end at 0.0."""
inv = AnimaDenoiseInvocation(
positive_conditioning=None, # type: ignore
transformer=None, # type: ignore
)
sigmas = inv._get_sigmas(30)
assert sigmas[0] == pytest.approx(loglinear_timestep_shift(ANIMA_SHIFT, 1.0))
assert sigmas[-1] == pytest.approx(0.0)
def test_schedule_monotonically_decreasing(self):
"""Sigmas should decrease from noise to clean."""
inv = AnimaDenoiseInvocation(
positive_conditioning=None, # type: ignore
transformer=None, # type: ignore
)
sigmas = inv._get_sigmas(30)
for i in range(len(sigmas) - 1):
assert sigmas[i] > sigmas[i + 1], f"Not decreasing at index {i}: {sigmas[i]} <= {sigmas[i + 1]}"
def test_schedule_uses_shift(self):
"""With shift=3.0, middle sigmas should be larger than the linear midpoint."""
inv = AnimaDenoiseInvocation(
positive_conditioning=None, # type: ignore
transformer=None, # type: ignore
)
sigmas = inv._get_sigmas(10)
# At step 5/10, linear t = 0.5, shifted sigma should be 0.75
assert sigmas[5] == pytest.approx(loglinear_timestep_shift(3.0, 0.5))
class TestGetSigmasEdgeCases:
"""Test edge cases for sigma schedule generation."""
def test_single_step_produces_valid_schedule(self):
"""_get_sigmas(num_steps=1) should produce a valid 2-element schedule."""
inv = AnimaDenoiseInvocation(
positive_conditioning=None, # type: ignore
transformer=None, # type: ignore
)
sigmas = inv._get_sigmas(1)
assert len(sigmas) == 2
assert sigmas[0] > sigmas[1]
assert sigmas[0] == pytest.approx(loglinear_timestep_shift(ANIMA_SHIFT, 1.0))
assert sigmas[-1] == pytest.approx(0.0)
class TestInverseLoglinearEdgeCases:
"""Test edge cases for inverse_loglinear_timestep_shift."""
def test_alpha_zero_does_not_divide_by_zero(self):
"""inverse_loglinear_timestep_shift with alpha=0 should not raise ZeroDivisionError.
With alpha=0: denominator = 0 - (0-1)*sigma = sigma.
At sigma=0, denominator=0 which hits the epsilon guard and returns 1.0.
At sigma>0, denominator=sigma, result = sigma/sigma = 1.0.
"""
# Should not raise
result = inverse_loglinear_timestep_shift(0.0, 0.5)
assert isinstance(result, float)
# At sigma=0, denominator would be 0 — should hit the epsilon guard
result_zero = inverse_loglinear_timestep_shift(0.0, 0.0)
assert isinstance(result_zero, float)
@@ -0,0 +1,55 @@
"""Dispatch wiring and sigma-contract tests for Anima ER-SDE.
Verifies that ANIMA_SCHEDULER_MAP['er_sde'] produces a correctly configured
ERSDEScheduler, that set_timesteps accepts sigmas= (the contract Anima relies
on to pass its pre-shifted schedule), and that the sigma state is set up as
expected after set_timesteps.
"""
from __future__ import annotations
from invokeai.backend.flux.schedulers import ANIMA_SCHEDULER_MAP
from invokeai.backend.rectified_flow.er_sde_scheduler import ERSDEScheduler
def test_anima_scheduler_map_er_sde_constructs_correctly():
"""The map entry must produce a valid ERSDEScheduler when instantiated."""
cls, kwargs = ANIMA_SCHEDULER_MAP["er_sde"]
scheduler = cls(num_train_timesteps=1000, **kwargs)
assert isinstance(scheduler, ERSDEScheduler)
assert scheduler.config.prediction_type == "flow_prediction"
assert scheduler.config.use_flow_sigmas is True
assert scheduler.config.solver_order == 3
assert scheduler.config.stochastic is True
def test_anima_er_sde_set_timesteps_accepts_sigmas():
"""Anima passes pre-shifted sigmas via set_timesteps(sigmas=...).
The legacy elif is_er_sde: branch consumed Anima's pre-shifted sigmas
directly. The universal path requires ERSDEScheduler.set_timesteps to
accept sigmas= as a keyword argument. This is the contract that makes
the cutover safe.
"""
import inspect
cls, kwargs = ANIMA_SCHEDULER_MAP["er_sde"]
scheduler = cls(num_train_timesteps=1000, **kwargs)
sig = inspect.signature(scheduler.set_timesteps)
assert "sigmas" in sig.parameters, "ERSDEScheduler.set_timesteps must accept sigmas= for Anima compatibility"
def test_anima_er_sde_set_timesteps_with_pre_shifted_sigmas():
"""End-to-end set_timesteps with a small pre-shifted sigma schedule."""
import torch
cls, kwargs = ANIMA_SCHEDULER_MAP["er_sde"]
scheduler = cls(num_train_timesteps=1000, **kwargs)
# Synthetic 5-step pre-shifted schedule, sigma_max=0.95 down to terminal 0.
sigmas = torch.tensor([0.95, 0.75, 0.5, 0.3, 0.1, 0.0], dtype=torch.float32)
scheduler.set_timesteps(sigmas=sigmas, device="cpu")
assert scheduler.num_inference_steps == 5
assert torch.allclose(scheduler.sigmas, sigmas)
# Multistep state must be reset.
assert scheduler.lower_order_nums == 0
assert all(x is None for x in scheduler.model_outputs)
+214
View File
@@ -0,0 +1,214 @@
"""Tests for the Anima VAE invocations: working-memory estimation, the tiled-decode
decision, and the tiled retry on out-of-memory."""
import math
from unittest.mock import MagicMock, patch
import pytest
import torch
from diffusers.models.autoencoders import AutoencoderKLWan
from invokeai.app.invocations.anima_image_to_latents import AnimaImageToLatentsInvocation
from invokeai.app.invocations.anima_latents_to_image import (
ANIMA_VAE_TILE_SIZE,
ANIMA_VAE_TILE_STRIDE,
AnimaLatentsToImageInvocation,
)
from invokeai.app.invocations.constants import LATENT_SCALE_FACTOR
from invokeai.backend.util.devices import TorchDevice
from invokeai.backend.util.vae_working_memory import estimate_vae_working_memory_anima
def _mock_wan_vae(dtype: torch.dtype = torch.float16) -> MagicMock:
vae = MagicMock(spec=AutoencoderKLWan)
param = torch.zeros(1, dtype=dtype)
# Return a fresh iterator on every call so the estimator can be called repeatedly.
vae.parameters.side_effect = lambda: iter([param])
return vae
class TestEstimateVaeWorkingMemoryAnima:
def test_untiled_decode_uses_decode_constant_and_scales_latent_dims(self):
latents = torch.zeros(1, 16, 1, 128, 128)
result = estimate_vae_working_memory_anima(
operation="decode", image_tensor=latents, vae=_mock_wan_vae(torch.float16), tile_size=None
)
out_h = out_w = 128 * LATENT_SCALE_FACTOR
assert result == int(out_h * out_w * 2 * 2900)
def test_untiled_encode_uses_encode_constant_and_pixel_dims(self):
image = torch.zeros(1, 3, 1, 1024, 1024)
result = estimate_vae_working_memory_anima(
operation="encode", image_tensor=image, vae=_mock_wan_vae(torch.float16), tile_size=None
)
assert result == int(1024 * 1024 * 2 * 1450)
@pytest.mark.parametrize("latent_hw", [(64, 64), (160, 160)])
def test_tiled_decode_estimate_is_independent_of_image_size(self, latent_hw):
latents = torch.zeros(1, 16, 1, *latent_hw)
result = estimate_vae_working_memory_anima(
operation="decode", image_tensor=latents, vae=_mock_wan_vae(torch.float16), tile_size=512
)
assert result == int(512 * 512 * 2 * 2900 * 1.25)
def test_estimate_scales_with_element_size(self):
latents = torch.zeros(1, 16, 1, 128, 128)
fp16 = estimate_vae_working_memory_anima(
operation="decode", image_tensor=latents, vae=_mock_wan_vae(torch.float16), tile_size=None
)
fp32 = estimate_vae_working_memory_anima(
operation="decode", image_tensor=latents, vae=_mock_wan_vae(torch.float32), tile_size=None
)
assert fp32 == 2 * fp16
class TestUseTiledDecode:
@pytest.mark.parametrize("device_type", ["cpu", "mps"])
def test_non_cuda_never_tiles(self, device_type):
assert AnimaLatentsToImageInvocation._use_tiled_decode(torch.device(device_type), 10**12) is False
def test_cuda_flips_at_70_percent_of_total_vram(self):
total_vram = 8 * 2**30
boundary = 0.7 * total_vram
device = torch.device("cuda")
with patch("torch.cuda.get_device_properties", return_value=MagicMock(total_memory=total_vram)) as mock_props:
assert AnimaLatentsToImageInvocation._use_tiled_decode(device, math.floor(boundary)) is False
assert AnimaLatentsToImageInvocation._use_tiled_decode(device, math.ceil(boundary) + 1) is True
mock_props.assert_called_with(device)
def _build_decode_mocks(latents: torch.Tensor, decoded: torch.Tensor):
"""Mock the Wan VAE decode path: a spec'd AutoencoderKLWan, its LoadedModel wrapper, and the
invocation context, wired so `AnimaLatentsToImageInvocation.invoke` runs end-to-end on CPU."""
vae = _mock_wan_vae(torch.float32)
vae.config.latents_mean = [0.0] * 16
vae.config.latents_std = [1.0] * 16
vae.decode.return_value = (decoded,)
vae_info = MagicMock()
vae_info.model = vae
cm = MagicMock()
cm.__enter__ = MagicMock(return_value=(None, vae))
cm.__exit__ = MagicMock(return_value=None)
vae_info.model_on_device.return_value = cm
context = MagicMock()
context.models.load.return_value = vae_info
context.tensors.load.return_value = latents
image_dto = MagicMock()
image_dto.image_name = "test.png"
image_dto.width = decoded.shape[-1]
image_dto.height = decoded.shape[-2]
context.images.save.return_value = image_dto
return vae, vae_info, context
def _build_l2i_invocation() -> AnimaLatentsToImageInvocation:
return AnimaLatentsToImageInvocation.model_construct(
latents=MagicMock(latents_name="test_latents"),
vae=MagicMock(vae=MagicMock()),
)
class TestAnimaLatentsToImageOomFallback:
@pytest.mark.parametrize(
"oom_error",
[
torch.cuda.OutOfMemoryError("CUDA out of memory. Tried to allocate 5.9 GiB"),
RuntimeError("CUDA error: out of memory"),
RuntimeError("cuDNN error: CUDNN_STATUS_ALLOC_FAILED"),
],
)
def test_untiled_decode_oom_retries_with_tiling(self, oom_error):
decoded = torch.zeros(1, 3, 1, 64, 64)
vae, _, context = _build_decode_mocks(latents=torch.zeros(1, 16, 32, 32), decoded=decoded)
vae.decode.side_effect = [oom_error, (decoded,)]
with patch.object(TorchDevice, "choose_torch_device", return_value=torch.device("cpu")):
result = _build_l2i_invocation().invoke(context)
assert vae.decode.call_count == 2
vae.enable_tiling.assert_called_once_with(
tile_sample_min_height=ANIMA_VAE_TILE_SIZE,
tile_sample_min_width=ANIMA_VAE_TILE_SIZE,
tile_sample_stride_height=ANIMA_VAE_TILE_STRIDE,
tile_sample_stride_width=ANIMA_VAE_TILE_STRIDE,
)
assert result.width == 64
def test_non_oom_runtime_error_propagates_without_retry(self):
vae, _, context = _build_decode_mocks(latents=torch.zeros(1, 16, 32, 32), decoded=torch.zeros(1, 3, 1, 64, 64))
vae.decode.side_effect = RuntimeError("Input type (float) and weight type (half) should be the same")
with patch.object(TorchDevice, "choose_torch_device", return_value=torch.device("cpu")):
with pytest.raises(RuntimeError, match="weight type"):
_build_l2i_invocation().invoke(context)
assert vae.decode.call_count == 1
vae.enable_tiling.assert_not_called()
def test_oom_while_already_tiled_reraises(self):
vae, _, context = _build_decode_mocks(latents=torch.zeros(1, 16, 32, 32), decoded=torch.zeros(1, 3, 1, 64, 64))
vae.decode.side_effect = torch.cuda.OutOfMemoryError("CUDA out of memory")
with (
patch.object(TorchDevice, "choose_torch_device", return_value=torch.device("cpu")),
patch.object(AnimaLatentsToImageInvocation, "_use_tiled_decode", return_value=True),
):
with pytest.raises(torch.cuda.OutOfMemoryError):
_build_l2i_invocation().invoke(context)
# No second attempt: the initial enable_tiling is the only one, and decode is not retried.
assert vae.decode.call_count == 1
vae.enable_tiling.assert_called_once()
def test_decode_requests_estimated_working_memory(self):
decoded = torch.zeros(1, 3, 1, 64, 64)
vae, vae_info, context = _build_decode_mocks(latents=torch.zeros(1, 16, 32, 32), decoded=decoded)
estimation_path = "invokeai.app.invocations.anima_latents_to_image.estimate_vae_working_memory_anima"
expected_memory = 1024 * 1024 * 500
with (
patch.object(TorchDevice, "choose_torch_device", return_value=torch.device("cpu")),
patch(estimation_path, return_value=expected_memory) as mock_estimate,
):
_build_l2i_invocation().invoke(context)
# Called once for the full-decode estimate (tiling decision) and once for the actual request.
assert mock_estimate.call_count == 2
vae_info.model_on_device.assert_called_once_with(working_mem_bytes=expected_memory)
class TestAnimaImageToLatentsEncode:
def test_encode_disables_tiling_and_requests_working_memory(self):
vae = _mock_wan_vae(torch.float32)
vae.config.latents_mean = [0.0] * 16
vae.config.latents_std = [1.0] * 16
mock_dist = MagicMock()
mock_dist.sample.return_value = torch.zeros(1, 16, 1, 4, 4)
vae.encode.return_value = (mock_dist,)
vae_info = MagicMock()
vae_info.model = vae
cm = MagicMock()
cm.__enter__ = MagicMock(return_value=(None, vae))
cm.__exit__ = MagicMock(return_value=None)
vae_info.model_on_device.return_value = cm
estimation_path = "invokeai.app.invocations.anima_image_to_latents.estimate_vae_working_memory_anima"
expected_memory = 1024 * 1024 * 250
with (
patch.object(TorchDevice, "choose_torch_device", return_value=torch.device("cpu")),
patch(estimation_path, return_value=expected_memory) as mock_estimate,
):
latents = AnimaImageToLatentsInvocation.vae_encode(
vae_info=vae_info, image_tensor=torch.zeros(1, 3, 32, 32)
)
# The shared cached VAE may have tiling enabled from a previous decode; encode must reset it.
vae.disable_tiling.assert_called_once()
vae.enable_tiling.assert_not_called()
mock_estimate.assert_called_once()
vae_info.model_on_device.assert_called_once_with(working_mem_bytes=expected_memory)
assert latents.shape == (1, 16, 4, 4)
@@ -0,0 +1,421 @@
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from invokeai.app.services.users.users_common import UserDTO
from invokeai.app.services.workflow_records.workflow_records_common import (
Workflow,
WorkflowCategory,
WorkflowMeta,
WorkflowNotFoundError,
WorkflowRecordDTO,
WorkflowWithoutIDValidator,
)
def build_workflow_record_dto(
*,
workflow_id: str = "workflow-123",
user_id: str = "owner-1",
category: WorkflowCategory = WorkflowCategory.User,
is_public: bool = False,
) -> WorkflowRecordDTO:
workflow = Workflow(
id=workflow_id,
name="Saved Workflow",
author="Tester",
description="",
version="1.0.0",
contact="",
tags="",
notes="",
exposedFields=[],
meta=WorkflowMeta(version="1.0.0", category=category),
nodes=[],
edges=[],
form=None,
)
return WorkflowRecordDTO(
workflow_id=workflow_id,
workflow=workflow,
name=workflow.name,
created_at="2026-04-08T00:00:00Z",
updated_at="2026-04-08T00:00:00Z",
opened_at=None,
user_id=user_id,
is_public=is_public,
)
def build_user_dto(*, user_id: str = "user-1", is_admin: bool = False) -> UserDTO:
return UserDTO(
user_id=user_id,
email=f"{user_id}@example.test",
display_name=user_id,
is_admin=is_admin,
is_active=True,
created_at="2026-04-08T00:00:00Z",
updated_at="2026-04-08T00:00:00Z",
last_login_at=None,
)
def build_context(
*,
workflow_record: WorkflowRecordDTO | None = None,
queue_user_id: str = "owner-1",
multiuser: bool = False,
user_is_admin: bool = False,
user_exists: bool = True,
workflow_not_found: bool = False,
):
services = SimpleNamespace(
configuration=SimpleNamespace(multiuser=multiuser),
users=Mock(),
workflow_records=Mock(),
)
services.users.get.return_value = (
build_user_dto(user_id=queue_user_id, is_admin=user_is_admin) if user_exists else None
)
if workflow_not_found:
services.workflow_records.get.side_effect = WorkflowNotFoundError("missing")
else:
services.workflow_records.get.return_value = workflow_record or build_workflow_record_dto()
context = Mock()
context._services = services
context._data = SimpleNamespace(queue_item=SimpleNamespace(user_id=queue_user_id))
return context
def test_call_saved_workflow_invocation_contract():
from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
from invokeai.app.invocations.workflow_return import WorkflowReturnOutput
invocation = CallSavedWorkflowInvocation(id="test-node", workflow_id="workflow-123")
assert invocation.get_type() == "call_saved_workflow"
assert invocation.workflow_id == "workflow-123"
output = invocation.invoke(build_context())
assert isinstance(output, WorkflowReturnOutput)
assert output.values == {}
def test_call_saved_workflow_invocation_raises_when_workflow_id_is_empty():
from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
invocation = CallSavedWorkflowInvocation(id="test-node")
with pytest.raises(ValueError, match="saved workflow must be selected"):
invocation.invoke(build_context())
def test_call_saved_workflow_invocation_raises_when_workflow_does_not_exist():
from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
invocation = CallSavedWorkflowInvocation(id="test-node", workflow_id="missing-workflow")
with pytest.raises(ValueError, match="could not be found"):
invocation.invoke(build_context(workflow_not_found=True))
def test_call_saved_workflow_invocation_raises_when_workflow_is_not_accessible():
from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
invocation = CallSavedWorkflowInvocation(id="test-node", workflow_id="private-workflow")
with pytest.raises(ValueError, match="is not accessible"):
invocation.invoke(
build_context(
workflow_record=build_workflow_record_dto(
workflow_id="private-workflow",
user_id="owner-1",
category=WorkflowCategory.User,
is_public=False,
),
queue_user_id="other-user",
multiuser=True,
user_is_admin=False,
)
)
def test_call_saved_workflow_invocation_allows_shared_workflow_for_non_owner():
from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
invocation = CallSavedWorkflowInvocation(id="test-node", workflow_id="shared-workflow")
output = invocation.invoke(
build_context(
workflow_record=build_workflow_record_dto(
workflow_id="shared-workflow",
user_id="owner-1",
category=WorkflowCategory.User,
is_public=True,
),
queue_user_id="other-user",
multiuser=True,
user_is_admin=False,
)
)
assert output.values == {}
def test_call_saved_workflow_invocation_allows_default_workflow_for_non_owner():
from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
invocation = CallSavedWorkflowInvocation(id="test-node", workflow_id="default-workflow")
output = invocation.invoke(
build_context(
workflow_record=build_workflow_record_dto(
workflow_id="default-workflow",
user_id="system",
category=WorkflowCategory.Default,
is_public=False,
),
queue_user_id="other-user",
multiuser=True,
user_is_admin=False,
)
)
assert output.values == {}
def test_call_saved_workflow_invocation_allows_admin_to_access_private_workflow():
from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
invocation = CallSavedWorkflowInvocation(id="test-node", workflow_id="private-workflow")
output = invocation.invoke(
build_context(
workflow_record=build_workflow_record_dto(
workflow_id="private-workflow",
user_id="owner-1",
category=WorkflowCategory.User,
is_public=False,
),
queue_user_id="admin-user",
multiuser=True,
user_is_admin=True,
)
)
assert output.values == {}
def test_call_saved_workflow_invocation_raises_when_private_workflow_user_record_is_missing():
from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
invocation = CallSavedWorkflowInvocation(id="test-node", workflow_id="private-workflow")
with pytest.raises(ValueError, match="is not accessible"):
invocation.invoke(
build_context(
workflow_record=build_workflow_record_dto(
workflow_id="private-workflow",
user_id="owner-1",
category=WorkflowCategory.User,
is_public=False,
),
queue_user_id="other-user",
multiuser=True,
user_exists=False,
)
)
def test_call_saved_workflow_invocation_schema_declares_saved_workflow_ui_type():
from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
schema = CallSavedWorkflowInvocation.model_json_schema()
workflow_id = schema["properties"]["workflow_id"]
workflow_inputs = schema["properties"]["workflow_inputs"]
assert workflow_id["default"] == ""
assert workflow_id["input"] == "any"
assert workflow_id["ui_type"] == "SavedWorkflowField"
assert workflow_inputs["default"] == {}
assert workflow_inputs["ui_hidden"] is True
def test_workflow_return_invocation_contract():
from invokeai.app.invocations.workflow_return import (
WorkflowReturnInvocation,
WorkflowReturnOutput,
WorkflowReturnValueField,
)
invocation = WorkflowReturnInvocation(
id="return-node",
values=[
WorkflowReturnValueField(key="prompt", value="a"),
WorkflowReturnValueField(key="count", value=1),
WorkflowReturnValueField(key="metadata", value={"x": True}),
],
)
assert invocation.get_type() == "workflow_return"
output = invocation.invoke(build_context())
assert isinstance(output, WorkflowReturnOutput)
assert output.values == {"prompt": "a", "count": 1, "metadata": {"x": True}}
assert not hasattr(output, "collection")
def test_workflow_return_invocation_accepts_single_return_value():
from invokeai.app.invocations.workflow_return import WorkflowReturnInvocation, WorkflowReturnValueField
invocation = WorkflowReturnInvocation(id="return-node", values=WorkflowReturnValueField(key="sum", value=3))
output = invocation.invoke(build_context())
assert output.values == {"sum": 3}
def test_workflow_return_values_schema_preserves_single_or_list_cardinality():
from invokeai.app.invocations.workflow_return import WorkflowReturnInvocation
values_schema = WorkflowReturnInvocation.model_json_schema()["properties"]["values"]
assert values_schema["anyOf"] == [
{"$ref": "#/$defs/WorkflowReturnValueField"},
{"items": {"$ref": "#/$defs/WorkflowReturnValueField"}, "type": "array"},
]
assert values_schema.get("ui_type") != "CollectionField"
def test_workflow_return_value_invocation_contract():
from invokeai.app.invocations.workflow_return import WorkflowReturnValueField, WorkflowReturnValueInvocation
invocation = WorkflowReturnValueInvocation(id="return-value-node", key="image", value={"image_name": "image-a"})
output = invocation.invoke(build_context())
assert output.value == WorkflowReturnValueField(key="image", value={"image_name": "image-a"})
def test_workflow_return_value_field_survives_exclude_none_session_roundtrip():
from invokeai.app.invocations.workflow_return import (
WorkflowReturnInvocation,
WorkflowReturnValueField,
WorkflowReturnValueOutput,
)
from invokeai.app.services.shared.graph import Graph, GraphExecutionState
graph = Graph()
graph.add_node(
WorkflowReturnInvocation(id="return-node", values=WorkflowReturnValueField(key="nullable", value=None))
)
session = GraphExecutionState(graph=graph)
session.execution_graph.add_node(
WorkflowReturnInvocation(id="return-node", values=WorkflowReturnValueField(key="nullable", value=None))
)
session.results["return-value-node"] = WorkflowReturnValueOutput(
value=WorkflowReturnValueField(key="nullable", value=None)
)
reloaded = GraphExecutionState.model_validate_json(session.model_dump_json(warnings=False, exclude_none=True))
assert reloaded.execution_graph.nodes["return-node"].values == WorkflowReturnValueField(key="nullable", value=None)
assert reloaded.results["return-value-node"].value == WorkflowReturnValueField(key="nullable", value=None)
def test_workflow_return_invocation_rejects_duplicate_keys():
from invokeai.app.invocations.workflow_return import WorkflowReturnInvocation, WorkflowReturnValueField
invocation = WorkflowReturnInvocation(
id="return-node",
values=[
WorkflowReturnValueField(key="image", value="image-a"),
WorkflowReturnValueField(key="image", value="image-b"),
],
)
with pytest.raises(ValueError, match="Duplicate workflow return key 'image'"):
invocation.invoke(build_context())
def test_workflow_return_get_invocation_contract():
from invokeai.app.invocations.workflow_return import WorkflowReturnGetInvocation
invocation = WorkflowReturnGetInvocation(id="return-get-node", values={"image": "image-a"}, key="image")
output = invocation.invoke(build_context())
assert output.value == "image-a"
def test_workflow_return_get_invocation_rejects_missing_key():
from invokeai.app.invocations.workflow_return import WorkflowReturnGetInvocation
invocation = WorkflowReturnGetInvocation(id="return-get-node", values={"image": "image-a"}, key="mask")
with pytest.raises(ValueError, match="Workflow return key 'mask' was not found"):
invocation.invoke(build_context())
def test_workflow_return_get_invocation_rejects_empty_key():
from invokeai.app.invocations.workflow_return import WorkflowReturnGetInvocation
invocation = WorkflowReturnGetInvocation(id="return-get-node", values={"image": "image-a"}, key=" ")
with pytest.raises(ValueError, match="Workflow return key must not be empty"):
invocation.invoke(build_context())
def test_workflow_return_invocation_schema_declares_named_values_contract():
from invokeai.app.invocations.workflow_return import WorkflowReturnGetInvocation, WorkflowReturnInvocation
schema = WorkflowReturnInvocation.model_json_schema()
assert "collection" not in schema["properties"]
values = schema["properties"]["values"]
assert values["input"] == "connection"
assert "ui_type" not in values
get_schema = WorkflowReturnGetInvocation.model_json_schema()
get_values = get_schema["properties"]["values"]
assert get_values["input"] == "connection"
assert get_values["ui_type"] == "AnyField"
def test_workflow_without_id_validator_rejects_duplicate_workflow_return_nodes():
with pytest.raises(ValueError, match="workflow_return"):
WorkflowWithoutIDValidator.validate_python(
{
"name": "Workflow With Duplicate Returns",
"author": "Tester",
"description": "",
"version": "1.0.0",
"contact": "",
"tags": "",
"notes": "",
"exposedFields": [],
"meta": {"version": "1.0.0", "category": "user"},
"nodes": [
{
"id": "return-1",
"type": "invocation",
"data": {"id": "return-1", "type": "workflow_return"},
"position": {"x": 0, "y": 0},
},
{
"id": "return-2",
"type": "invocation",
"data": {"id": "return-2", "type": "workflow_return"},
"position": {"x": 100, "y": 0},
},
],
"edges": [],
"form": None,
}
)
@@ -0,0 +1,80 @@
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import MagicMock
import torch
from invokeai.app.invocations.cogview4_text_encoder import CogView4TextEncoderInvocation
class FakeGlmModel(torch.nn.Module):
def __init__(self):
super().__init__()
self.register_parameter("weight", torch.nn.Parameter(torch.ones(1)))
self.repaired = False
self.forward_input_device: torch.device | None = None
def forward(self, input_ids: torch.Tensor, output_hidden_states: bool = False):
assert output_hidden_states
if not self.repaired:
raise RuntimeError("model must be repaired before forward")
self.forward_input_device = input_ids.device
hidden = input_ids.unsqueeze(-1).float()
return SimpleNamespace(hidden_states=[hidden, hidden + 1])
class FakeTokenizer:
pad_token_id = 0
def __call__(self, prompt, padding, max_length=None, truncation=None, add_special_tokens=None, return_tensors=None):
del prompt, padding, max_length, truncation, add_special_tokens, return_tensors
return SimpleNamespace(input_ids=torch.tensor([[1, 2, 3]], dtype=torch.long))
def batch_decode(self, input_ids):
del input_ids
return ["decoded"]
class FakeLoadedModel:
def __init__(self, model):
self._model = model
self.repair_calls = 0
@contextmanager
def model_on_device(self):
yield (None, self._model)
def repair_required_tensors_on_device(self) -> int:
self.repair_calls += 1
self._model.repaired = True
return 1
def test_cogview4_text_encoder_repairs_model_before_forward(monkeypatch):
fake_model = FakeGlmModel()
fake_tokenizer = FakeTokenizer()
fake_model_info = FakeLoadedModel(fake_model)
fake_tokenizer_info = FakeLoadedModel(fake_tokenizer)
mock_context = MagicMock()
mock_context.models.load.side_effect = [fake_model_info, fake_tokenizer_info]
mock_context.util.signal_progress = MagicMock()
mock_context.logger.warning = MagicMock()
invocation = CogView4TextEncoderInvocation.model_construct(
prompt="test prompt",
glm_encoder=SimpleNamespace(text_encoder=SimpleNamespace(), tokenizer=SimpleNamespace()),
)
module_path = "invokeai.app.invocations.cogview4_text_encoder"
monkeypatch.setattr(f"{module_path}.GlmModel", FakeGlmModel)
monkeypatch.setattr(f"{module_path}.PreTrainedTokenizerFast", FakeTokenizer)
embeds = invocation._glm_encode(mock_context, max_seq_len=16)
assert fake_model_info.repair_calls == 1
mock_context.logger.warning.assert_called_once()
mock_context.util.signal_progress.assert_called_once_with("Running GLM text encoder")
assert fake_model.forward_input_device == torch.device("cpu")
assert embeds.shape == (1, 16, 1)
+139
View File
@@ -0,0 +1,139 @@
from contextlib import contextmanager, nullcontext
from types import SimpleNamespace
from unittest.mock import MagicMock
import torch
from invokeai.app.invocations.compel import SDXLPromptInvocationBase
class FakeClipTextEncoder(torch.nn.Module):
def __init__(self, effective_device: torch.device):
super().__init__()
self.register_parameter("cpu_param", torch.nn.Parameter(torch.ones(1)))
self.register_buffer("active_buffer", torch.ones(1, device=effective_device))
self.dtype = torch.float32
@property
def device(self) -> torch.device:
return torch.device("cpu")
class FakeTokenizer:
pass
class FakeLoadedModel:
def __init__(self, model, config=None):
self._model = model
self.config = config
@contextmanager
def model_on_device(self):
yield (None, self._model)
def __enter__(self):
return self._model
def __exit__(self, exc_type, exc, tb):
return False
class FakeCompel:
last_init_device: torch.device | None = None
def __init__(self, *args, device: torch.device, **kwargs):
del args, kwargs
FakeCompel.last_init_device = device
self.conditioning_provider = SimpleNamespace(
get_pooled_embeddings=lambda prompts: torch.ones((len(prompts), 4), dtype=torch.float32)
)
@staticmethod
def parse_prompt_string(prompt: str) -> str:
return prompt
def build_conditioning_tensor_for_conjunction(self, conjunction: str):
del conjunction
return torch.ones((1, 4, 4), dtype=torch.float32), {}
@contextmanager
def fake_apply_ti(tokenizer, text_encoder, ti_list):
del text_encoder, ti_list
yield tokenizer, object()
def test_sdxl_run_clip_compel_uses_effective_device_for_partially_loaded_model(monkeypatch):
module_path = "invokeai.app.invocations.compel"
effective_device = torch.device("meta")
text_encoder = FakeClipTextEncoder(effective_device=effective_device)
tokenizer = FakeTokenizer()
text_encoder_info = FakeLoadedModel(text_encoder, config=SimpleNamespace(base="sdxl"))
tokenizer_info = FakeLoadedModel(tokenizer)
mock_context = MagicMock()
mock_context.models.load.side_effect = [text_encoder_info, tokenizer_info]
mock_context.config.get.return_value.log_tokenization = False
mock_context.util.signal_progress = MagicMock()
monkeypatch.setattr(f"{module_path}.CLIPTextModel", FakeClipTextEncoder)
monkeypatch.setattr(f"{module_path}.CLIPTextModelWithProjection", FakeClipTextEncoder)
monkeypatch.setattr(f"{module_path}.CLIPTokenizer", FakeTokenizer)
monkeypatch.setattr(f"{module_path}.Compel", FakeCompel)
monkeypatch.setattr(f"{module_path}.generate_ti_list", lambda prompt, base, context: [])
monkeypatch.setattr(f"{module_path}.LayerPatcher.apply_smart_model_patches", lambda **kwargs: nullcontext())
monkeypatch.setattr(f"{module_path}.ModelPatcher.apply_clip_skip", lambda *args, **kwargs: nullcontext())
monkeypatch.setattr(f"{module_path}.ModelPatcher.apply_ti", fake_apply_ti)
base = SDXLPromptInvocationBase()
cond, pooled = base.run_clip_compel(
context=mock_context,
clip_field=SimpleNamespace(
text_encoder=SimpleNamespace(), tokenizer=SimpleNamespace(), loras=[], skipped_layers=0
),
prompt="test prompt",
get_pooled=False,
lora_prefix="lora_te1_",
zero_on_empty=False,
)
assert FakeCompel.last_init_device == effective_device
assert cond.shape == (1, 4, 4)
assert pooled is None
def test_sdxl_run_clip_compel_uses_cpu_for_fully_cpu_model(monkeypatch):
module_path = "invokeai.app.invocations.compel"
text_encoder = FakeClipTextEncoder(effective_device=torch.device("cpu"))
tokenizer = FakeTokenizer()
text_encoder_info = FakeLoadedModel(text_encoder, config=SimpleNamespace(base="sdxl"))
tokenizer_info = FakeLoadedModel(tokenizer)
mock_context = MagicMock()
mock_context.models.load.side_effect = [text_encoder_info, tokenizer_info]
mock_context.config.get.return_value.log_tokenization = False
mock_context.util.signal_progress = MagicMock()
monkeypatch.setattr(f"{module_path}.CLIPTextModel", FakeClipTextEncoder)
monkeypatch.setattr(f"{module_path}.CLIPTextModelWithProjection", FakeClipTextEncoder)
monkeypatch.setattr(f"{module_path}.CLIPTokenizer", FakeTokenizer)
monkeypatch.setattr(f"{module_path}.Compel", FakeCompel)
monkeypatch.setattr(f"{module_path}.generate_ti_list", lambda prompt, base, context: [])
monkeypatch.setattr(f"{module_path}.LayerPatcher.apply_smart_model_patches", lambda **kwargs: nullcontext())
monkeypatch.setattr(f"{module_path}.ModelPatcher.apply_clip_skip", lambda *args, **kwargs: nullcontext())
monkeypatch.setattr(f"{module_path}.ModelPatcher.apply_ti", fake_apply_ti)
base = SDXLPromptInvocationBase()
base.run_clip_compel(
context=mock_context,
clip_field=SimpleNamespace(
text_encoder=SimpleNamespace(), tokenizer=SimpleNamespace(), loras=[], skipped_layers=0
),
prompt="test prompt",
get_pooled=False,
lora_prefix="lora_te1_",
zero_on_empty=False,
)
assert FakeCompel.last_init_device == torch.device("cpu")
@@ -0,0 +1,664 @@
import inspect
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
import torch
from invokeai.app.invocations.anima_denoise import AnimaDenoiseInvocation
from invokeai.app.invocations.cogview4_denoise import CogView4DenoiseInvocation
from invokeai.app.invocations.flux2_denoise import Flux2DenoiseInvocation
from invokeai.app.invocations.flux_denoise import FluxDenoiseInvocation
from invokeai.app.invocations.metadata_linked import FluxDenoiseLatentsMetaInvocation, ZImageDenoiseMetaInvocation
from invokeai.app.invocations.primitives import LatentsOutput
from invokeai.app.invocations.sd3_denoise import SD3DenoiseInvocation
from invokeai.app.invocations.z_image_denoise import ZImageDenoiseInvocation
from invokeai.backend.flux.sampling_utils import clip_timestep_schedule_fractional, get_schedule
from invokeai.backend.flux.schedulers import ANIMA_SCHEDULER_MAP, FLUX_SCHEDULER_MAP, ZIMAGE_SCHEDULER_MAP
from invokeai.backend.flux2.sampling_utils import get_schedule_flux2
from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType
def test_flux_prepare_noise_uses_external_noise():
invocation = FluxDenoiseInvocation.model_construct(
width=64, height=64, seed=0, noise=MagicMock(latents_name="noise")
)
mock_context = MagicMock()
expected = torch.zeros(1, 16, 8, 8)
mock_context.tensors.load.return_value = expected
with patch("invokeai.app.invocations.flux_denoise.get_noise") as mock_get_noise:
noise = invocation._prepare_noise_tensor(mock_context, torch.bfloat16, torch.device("cpu"))
assert torch.equal(noise, expected.to(dtype=torch.bfloat16))
mock_get_noise.assert_not_called()
def test_flux_prepare_noise_rejects_invalid_shape():
invocation = FluxDenoiseInvocation.model_construct(
width=64, height=64, seed=0, noise=MagicMock(latents_name="noise")
)
mock_context = MagicMock()
mock_context.tensors.load.return_value = torch.zeros(1, 15, 8, 8)
with pytest.raises(ValueError, match="Expected noise with shape"):
invocation._prepare_noise_tensor(mock_context, torch.bfloat16, torch.device("cpu"))
def test_flux_add_noise_false_ignores_connected_noise():
invocation = FluxDenoiseInvocation.model_construct(
latents=MagicMock(latents_name="latents"),
noise=MagicMock(latents_name="noise"),
add_noise=False,
width=64,
height=64,
num_steps=4,
denoising_start=0.25,
denoising_end=0.25,
positive_text_conditioning=MagicMock(conditioning_name="positive"),
transformer=MagicMock(transformer="transformer"),
seed=123,
)
init_latents = torch.full((1, 16, 8, 8), 2.0)
dummy_conditioning = SimpleNamespace(
t5_embeds=torch.zeros(1, 4, 16),
clip_embeds=torch.zeros(1, 768),
to=lambda **_: dummy_conditioning,
)
mock_context = MagicMock()
mock_context.tensors.load.return_value = init_latents
mock_context.conditioning.load.return_value = SimpleNamespace(conditionings=[dummy_conditioning])
mock_context.models.get_config.return_value = SimpleNamespace(
base=BaseModelType.Flux, type=ModelType.Main, variant=None
)
with (
patch(
"invokeai.app.invocations.flux_denoise.TorchDevice.choose_torch_device", return_value=torch.device("cpu")
),
patch("invokeai.app.invocations.flux_denoise.FLUXConditioningInfo", object),
patch(
"invokeai.app.invocations.flux_denoise.RegionalPromptingExtension.from_text_conditioning",
return_value=MagicMock(),
),
patch.object(invocation, "_prepare_noise_tensor", side_effect=AssertionError("noise should be ignored")),
patch.object(invocation, "_load_redux_conditioning", return_value=[]),
patch("invokeai.app.invocations.flux_denoise.get_schedule", return_value=[0.75]),
):
result = invocation._run_diffusion(mock_context)
assert torch.equal(result, init_latents)
def test_flux2_prepare_noise_uses_external_noise():
invocation = Flux2DenoiseInvocation.model_construct(
width=64, height=64, seed=0, noise=MagicMock(latents_name="noise")
)
mock_context = MagicMock()
expected = torch.zeros(1, 32, 8, 8)
mock_context.tensors.load.return_value = expected
with patch("invokeai.app.invocations.flux2_denoise.get_noise_flux2") as mock_get_noise:
noise = invocation._prepare_noise_tensor(mock_context, torch.bfloat16, torch.device("cpu"))
assert torch.equal(noise, expected.to(dtype=torch.bfloat16))
mock_get_noise.assert_not_called()
def test_flux2_prepare_noise_rejects_invalid_shape():
invocation = Flux2DenoiseInvocation.model_construct(
width=64, height=64, seed=0, noise=MagicMock(latents_name="noise")
)
mock_context = MagicMock()
mock_context.tensors.load.return_value = torch.zeros(1, 16, 8, 8)
with pytest.raises(ValueError, match="Expected noise with shape"):
invocation._prepare_noise_tensor(mock_context, torch.bfloat16, torch.device("cpu"))
def test_sd3_prepare_noise_uses_external_noise():
invocation = SD3DenoiseInvocation.model_construct(
width=64, height=64, seed=0, noise=MagicMock(latents_name="noise")
)
mock_context = MagicMock()
expected = torch.zeros(1, 16, 8, 8)
mock_context.tensors.load.return_value = expected
with patch.object(invocation, "_get_noise") as mock_get_noise:
noise = invocation._prepare_noise_tensor(mock_context, 16, torch.bfloat16, torch.device("cpu"))
assert torch.equal(noise, expected.to(dtype=torch.bfloat16))
mock_get_noise.assert_not_called()
def test_sd3_prepare_noise_rejects_invalid_shape():
invocation = SD3DenoiseInvocation.model_construct(
width=64, height=64, seed=0, noise=MagicMock(latents_name="noise")
)
mock_context = MagicMock()
mock_context.tensors.load.return_value = torch.zeros(1, 8, 8, 8)
with pytest.raises(ValueError, match="Expected noise with shape"):
invocation._prepare_noise_tensor(mock_context, 16, torch.bfloat16, torch.device("cpu"))
def test_cogview4_prepare_noise_uses_external_noise():
invocation = CogView4DenoiseInvocation.model_construct(
width=64, height=64, seed=0, noise=MagicMock(latents_name="noise")
)
mock_context = MagicMock()
expected = torch.zeros(1, 16, 8, 8)
mock_context.tensors.load.return_value = expected
with patch.object(invocation, "_get_noise") as mock_get_noise:
noise = invocation._prepare_noise_tensor(mock_context, 16, torch.bfloat16, torch.device("cpu"))
assert torch.equal(noise, expected.to(dtype=torch.bfloat16))
mock_get_noise.assert_not_called()
def test_cogview4_prepare_noise_rejects_invalid_shape():
invocation = CogView4DenoiseInvocation.model_construct(
width=64, height=64, seed=0, noise=MagicMock(latents_name="noise")
)
mock_context = MagicMock()
mock_context.tensors.load.return_value = torch.zeros(1, 4, 8, 8)
with pytest.raises(ValueError, match="Expected noise with shape"):
invocation._prepare_noise_tensor(mock_context, 16, torch.bfloat16, torch.device("cpu"))
def test_z_image_prepare_noise_uses_external_noise():
invocation = ZImageDenoiseInvocation.model_construct(
width=64, height=64, seed=0, noise=MagicMock(latents_name="noise")
)
mock_context = MagicMock()
expected = torch.zeros(1, 16, 8, 8)
mock_context.tensors.load.return_value = expected
with patch.object(invocation, "_get_noise") as mock_get_noise:
noise = invocation._prepare_noise_tensor(mock_context, torch.bfloat16, torch.device("cpu"))
assert torch.equal(noise, expected.to(dtype=torch.bfloat16))
mock_get_noise.assert_not_called()
def test_z_image_prepare_noise_rejects_invalid_shape():
invocation = ZImageDenoiseInvocation.model_construct(
width=64, height=64, seed=0, noise=MagicMock(latents_name="noise")
)
mock_context = MagicMock()
mock_context.tensors.load.return_value = torch.zeros(1, 8, 8, 8)
with pytest.raises(ValueError, match="Expected noise with shape"):
invocation._prepare_noise_tensor(mock_context, torch.bfloat16, torch.device("cpu"))
def test_z_image_add_noise_false_ignores_connected_noise():
invocation = ZImageDenoiseInvocation.model_construct(
latents=MagicMock(latents_name="latents"),
noise=MagicMock(latents_name="noise"),
add_noise=False,
width=64,
height=64,
steps=4,
denoising_start=0.0,
denoising_end=1.0,
positive_conditioning=SimpleNamespace(conditioning_name="positive", mask=None),
transformer=MagicMock(transformer="transformer"),
seed=123,
scheduler="euler",
)
init_latents = torch.full((1, 16, 8, 8), 2.0)
dummy_conditioning = SimpleNamespace(prompt_embeds=torch.zeros(1, 4, 16))
dummy_conditioning.to = lambda **_: dummy_conditioning
regional_extension = SimpleNamespace(
regional_text_conditioning=SimpleNamespace(prompt_embeds=torch.zeros(1, 4, 16))
)
loaded_text_conditioning = [SimpleNamespace(prompt_embeds=torch.zeros(1, 4, 16), mask=None)]
mock_context = MagicMock()
mock_context.tensors.load.return_value = init_latents
mock_context.conditioning.load.return_value = SimpleNamespace(conditionings=[dummy_conditioning])
with (
patch(
"invokeai.app.invocations.z_image_denoise.TorchDevice.choose_torch_device", return_value=torch.device("cpu")
),
patch(
"invokeai.app.invocations.z_image_denoise.TorchDevice.choose_bfloat16_safe_dtype",
return_value=torch.bfloat16,
),
patch("invokeai.app.invocations.z_image_denoise.ZImageConditioningInfo", object),
patch(
"invokeai.app.invocations.z_image_denoise.ZImageRegionalPromptingExtension.from_text_conditionings",
return_value=regional_extension,
),
patch.object(invocation, "_load_text_conditioning", return_value=loaded_text_conditioning),
patch.object(invocation, "_prepare_noise_tensor", side_effect=AssertionError("noise should be ignored")),
patch.object(invocation, "_get_sigmas", return_value=[0.75]),
):
result = invocation._run_diffusion(mock_context)
assert torch.equal(result, init_latents)
def test_anima_prepare_noise_uses_external_noise():
invocation = AnimaDenoiseInvocation.model_construct(
width=64, height=64, seed=0, noise=MagicMock(latents_name="noise")
)
mock_context = MagicMock()
expected = torch.zeros(1, 16, 1, 8, 8)
mock_context.tensors.load.return_value = expected
with patch.object(invocation, "_get_noise") as mock_get_noise:
noise = invocation._prepare_noise_tensor(mock_context, torch.bfloat16, torch.device("cpu"))
assert torch.equal(noise, expected.to(dtype=torch.bfloat16))
mock_get_noise.assert_not_called()
def test_anima_prepare_noise_rejects_invalid_rank():
invocation = AnimaDenoiseInvocation.model_construct(
width=64, height=64, seed=0, noise=MagicMock(latents_name="noise")
)
mock_context = MagicMock()
mock_context.tensors.load.return_value = torch.zeros(1, 16, 8, 8)
with pytest.raises(ValueError, match="Expected noise with shape"):
invocation._prepare_noise_tensor(mock_context, torch.bfloat16, torch.device("cpu"))
def test_anima_add_noise_false_ignores_connected_noise():
invocation = AnimaDenoiseInvocation.model_construct(
latents=MagicMock(latents_name="latents"),
noise=MagicMock(latents_name="noise"),
add_noise=False,
width=64,
height=64,
steps=4,
denoising_start=0.0,
denoising_end=1.0,
positive_conditioning=SimpleNamespace(conditioning_name="positive", mask=None),
transformer=MagicMock(transformer="transformer"),
seed=123,
scheduler="euler",
)
init_latents = torch.full((1, 16, 8, 8), 2.0)
loaded_text_conditioning = [SimpleNamespace(mask=None)]
mock_context = MagicMock()
mock_context.tensors.load.return_value = init_latents
mock_context.models.load.return_value = MagicMock()
with (
patch(
"invokeai.app.invocations.anima_denoise.TorchDevice.choose_torch_device", return_value=torch.device("cpu")
),
patch(
"invokeai.app.invocations.anima_denoise.TorchDevice.choose_bfloat16_safe_dtype", return_value=torch.bfloat16
),
patch.object(invocation, "_load_text_conditionings", return_value=loaded_text_conditioning),
patch.object(invocation, "_prepare_noise_tensor", side_effect=AssertionError("noise should be ignored")),
patch.object(invocation, "_get_sigmas", return_value=[0.75]),
):
result = invocation._run_diffusion(mock_context)
assert torch.equal(result, init_latents)
def test_flux2_add_noise_false_ignores_connected_noise():
invocation = Flux2DenoiseInvocation.model_construct(
latents=MagicMock(latents_name="latents"),
noise=MagicMock(latents_name="noise"),
add_noise=False,
width=64,
height=64,
num_steps=4,
denoising_start=0.25,
denoising_end=0.25,
positive_text_conditioning=MagicMock(conditioning_name="positive"),
transformer=MagicMock(transformer="transformer"),
vae=MagicMock(vae="vae"),
seed=123,
)
init_latents = torch.full((1, 32, 8, 8), 2.0)
mock_context = MagicMock()
mock_context.tensors.load.return_value = init_latents
mock_context.conditioning.load.return_value = SimpleNamespace(
conditionings=[
SimpleNamespace(
t5_embeds=torch.zeros(1, 4, 16), to=lambda **_: SimpleNamespace(t5_embeds=torch.zeros(1, 4, 16))
)
]
)
mock_context.models.get_config.return_value = SimpleNamespace(base=BaseModelType.Flux2, type=ModelType.Main)
with (
patch(
"invokeai.app.invocations.flux2_denoise.TorchDevice.choose_torch_device", return_value=torch.device("cpu")
),
patch("invokeai.app.invocations.flux2_denoise.FLUXConditioningInfo", object),
patch.object(invocation, "_get_bn_stats", return_value=None),
patch.object(invocation, "_prepare_noise_tensor", side_effect=AssertionError("noise should be ignored")),
):
result = invocation._run_diffusion(mock_context)
assert torch.equal(result, init_latents)
def test_flux_metadata_ignores_external_noise_seed_when_noise_not_used():
invocation = FluxDenoiseLatentsMetaInvocation.model_construct(
width=64,
height=64,
num_steps=4,
guidance=3.5,
denoising_start=0.0,
denoising_end=1.0,
latents=MagicMock(latents_name="latents"),
transformer=MagicMock(transformer="transformer", loras=[]),
noise=MagicMock(seed=123),
seed=999,
add_noise=False,
)
mock_context = MagicMock()
output = LatentsOutput.build("latents", torch.zeros(1, 16, 8, 8), seed=None)
with patch("invokeai.app.invocations.metadata_linked.FluxDenoiseInvocation.invoke", return_value=output):
result = invocation.invoke(mock_context)
assert result.metadata.root["seed"] == 999
def test_z_image_metadata_ignores_external_noise_seed_when_noise_not_used():
invocation = ZImageDenoiseMetaInvocation.model_construct(
width=64,
height=64,
steps=8,
guidance_scale=1.0,
denoising_start=0.0,
denoising_end=1.0,
scheduler="euler",
latents=MagicMock(latents_name="latents"),
transformer=MagicMock(transformer="transformer", loras=[]),
noise=MagicMock(seed=123),
seed=999,
add_noise=False,
)
mock_context = MagicMock()
output = LatentsOutput.build("latents", torch.zeros(1, 16, 8, 8), seed=None)
with patch("invokeai.app.invocations.metadata_linked.ZImageDenoiseInvocation.invoke", return_value=output):
result = invocation.invoke(mock_context)
assert result.metadata.root["seed"] == 999
def _get_first_scheduler_sigma(
scheduler, *, scheduler_name: str, sigmas: list[float], mu: float | None = None
) -> float:
set_timesteps_signature = inspect.signature(scheduler.set_timesteps)
if scheduler_name != "lcm" and "sigmas" in set_timesteps_signature.parameters:
kwargs: dict[str, object] = {"sigmas": sigmas, "device": "cpu"}
if mu is not None and "mu" in set_timesteps_signature.parameters:
kwargs["mu"] = mu
scheduler.set_timesteps(**kwargs)
else:
kwargs = {"num_inference_steps": len(sigmas) - 1, "device": "cpu"}
if mu is not None and "mu" in set_timesteps_signature.parameters:
kwargs["mu"] = mu
scheduler.set_timesteps(**kwargs)
return float(scheduler.sigmas[0])
@pytest.mark.parametrize(
"scheduler_name",
[
"euler",
pytest.param(
"heun",
marks=pytest.mark.xfail(
reason="Known img2img preblend mismatch for FLUX with scheduler-defined first step.",
strict=True,
),
),
pytest.param(
"lcm",
marks=pytest.mark.xfail(
reason="Known img2img preblend mismatch for FLUX with scheduler-defined first step.",
strict=True,
),
),
],
)
def test_flux_img2img_preblend_matches_scheduler_first_sigma(scheduler_name: str):
sigmas = clip_timestep_schedule_fractional(get_schedule(num_steps=4, image_seq_len=16, shift=True), 0.25, 1.0)
scheduler_class = FLUX_SCHEDULER_MAP[scheduler_name]
scheduler = scheduler_class(num_train_timesteps=1000)
assert sigmas[0] == pytest.approx(
_get_first_scheduler_sigma(scheduler, scheduler_name=scheduler_name, sigmas=sigmas)
)
def test_flux2_partial_denoise_short_circuit_uses_first_clipped_timestep():
invocation = Flux2DenoiseInvocation.model_construct(
latents=MagicMock(latents_name="latents"),
width=64,
height=64,
num_steps=4,
denoising_start=0.25,
denoising_end=0.25,
positive_text_conditioning=MagicMock(conditioning_name="positive"),
transformer=MagicMock(transformer="transformer"),
vae=MagicMock(vae="vae"),
seed=0,
scheduler="lcm",
)
init_latents = torch.full((1, 32, 8, 8), 2.0)
noise = torch.full((1, 32, 8, 8), 10.0)
dummy_conditioning = SimpleNamespace(t5_embeds=torch.zeros(1, 4, 16))
dummy_conditioning.to = lambda **_: dummy_conditioning
mock_context = MagicMock()
mock_context.tensors.load.return_value = init_latents
mock_context.conditioning.load.return_value = SimpleNamespace(conditionings=[dummy_conditioning])
mock_context.models.get_config.return_value = SimpleNamespace(base=BaseModelType.Flux2, type=ModelType.Main)
with (
patch(
"invokeai.app.invocations.flux2_denoise.TorchDevice.choose_torch_device", return_value=torch.device("cpu")
),
patch("invokeai.app.invocations.flux2_denoise.FLUXConditioningInfo", object),
patch.object(invocation, "_get_bn_stats", return_value=None),
patch.object(invocation, "_prepare_noise_tensor", return_value=noise),
):
result = invocation._run_diffusion(mock_context)
timesteps = clip_timestep_schedule_fractional(get_schedule_flux2(num_steps=4, image_seq_len=16), 0.25, 0.25)
expected = timesteps[0] * noise + (1.0 - timesteps[0]) * init_latents
assert torch.equal(result, expected)
def test_flux2_lcm_scheduler_setup_passes_mu():
from invokeai.backend.flux2.denoise import denoise
class DummyScheduler:
def __init__(self) -> None:
self.received_mu = None
self.timesteps = torch.tensor([750.0, 500.0], dtype=torch.float32)
self.sigmas = torch.tensor([0.75, 0.5, 0.0], dtype=torch.float32)
self.config = SimpleNamespace(num_train_timesteps=1000)
def set_timesteps(self, num_inference_steps: int, device: str | torch.device, mu: float | None = None) -> None:
self.received_mu = mu
def step(self, model_output: torch.Tensor, timestep: torch.Tensor, sample: torch.Tensor):
return SimpleNamespace(prev_sample=sample)
class DummyModel(torch.nn.Module):
def forward(
self,
hidden_states: torch.Tensor,
encoder_hidden_states: torch.Tensor,
timestep: torch.Tensor,
img_ids: torch.Tensor,
txt_ids: torch.Tensor,
guidance: torch.Tensor,
return_dict: bool = False,
):
return (torch.zeros_like(hidden_states),)
scheduler = DummyScheduler()
denoise(
model=DummyModel(),
img=torch.zeros(1, 4, 8),
img_ids=torch.zeros(1, 4, 4, dtype=torch.long),
txt=torch.zeros(1, 4, 8),
txt_ids=torch.zeros(1, 4, 4, dtype=torch.long),
timesteps=[0.75, 0.5, 0.0],
step_callback=lambda _: None,
guidance=1.0,
cfg_scale=[1.0, 1.0],
scheduler=scheduler,
mu=0.42,
)
assert scheduler.received_mu == pytest.approx(0.42)
@pytest.mark.parametrize(
"scheduler_name",
[
"euler",
pytest.param(
"heun",
marks=pytest.mark.xfail(
reason="Known img2img preblend mismatch for Z-Image with scheduler-defined first step.",
strict=True,
),
),
pytest.param(
"lcm",
marks=pytest.mark.xfail(
reason="Known img2img preblend mismatch for Z-Image with scheduler-defined first step.",
strict=True,
),
),
],
)
def test_z_image_img2img_preblend_matches_scheduler_first_sigma(scheduler_name: str):
invocation = ZImageDenoiseInvocation.model_construct(steps=8, width=1024, height=1024)
img_seq_len = (invocation.height // 8 // 2) * (invocation.width // 8 // 2)
shift = invocation._calculate_shift(img_seq_len)
sigmas = invocation._get_sigmas(shift, invocation.steps)
sigmas = sigmas[int(0.25 * (len(sigmas) - 1)) :]
scheduler_class = ZIMAGE_SCHEDULER_MAP[scheduler_name]
scheduler = scheduler_class(num_train_timesteps=1000, shift=1.0)
assert sigmas[0] == pytest.approx(
_get_first_scheduler_sigma(scheduler, scheduler_name=scheduler_name, sigmas=sigmas)
)
@pytest.mark.parametrize(
"scheduler_name",
[
"euler",
pytest.param(
"heun",
marks=pytest.mark.xfail(
reason="Known img2img preblend mismatch for Anima with scheduler-defined first step.",
strict=True,
),
),
pytest.param(
"lcm",
marks=pytest.mark.xfail(
reason="Known img2img preblend mismatch for Anima with scheduler-defined first step.",
strict=True,
),
),
],
)
def test_anima_img2img_preblend_matches_scheduler_first_sigma(scheduler_name: str):
invocation = AnimaDenoiseInvocation.model_construct(steps=30)
sigmas = invocation._get_sigmas(invocation.steps)
sigmas = sigmas[int(0.25 * (len(sigmas) - 1)) :]
scheduler_class, scheduler_kwargs = ANIMA_SCHEDULER_MAP[scheduler_name]
scheduler = scheduler_class(num_train_timesteps=1000, **scheduler_kwargs)
assert sigmas[0] == pytest.approx(
_get_first_scheduler_sigma(scheduler, scheduler_name=scheduler_name, sigmas=sigmas)
)
def test_sd3_partial_denoise_short_circuit_uses_first_clipped_timestep():
invocation = SD3DenoiseInvocation.model_construct(
latents=MagicMock(latents_name="latents"),
width=64,
height=64,
steps=4,
denoising_start=0.25,
denoising_end=0.25,
positive_conditioning=MagicMock(conditioning_name="positive"),
negative_conditioning=MagicMock(conditioning_name="negative"),
transformer=MagicMock(transformer="transformer"),
seed=0,
)
init_latents = torch.full((1, 16, 8, 8), 2.0)
noise = torch.full((1, 16, 8, 8), 10.0)
mock_context = MagicMock()
mock_context.tensors.load.return_value = init_latents
mock_context.models.load.return_value = MagicMock(
model=MagicMock(config=MagicMock(in_channels=16, joint_attention_dim=4096))
)
with (
patch("invokeai.app.invocations.sd3_denoise.TorchDevice.choose_torch_device", return_value=torch.device("cpu")),
patch("invokeai.app.invocations.sd3_denoise.TorchDevice.choose_torch_dtype", return_value=torch.float32),
patch.object(invocation, "_prepare_noise_tensor", return_value=noise),
patch.object(invocation, "_load_text_conditioning", return_value=(torch.zeros(1, 1, 1), torch.zeros(1, 1))),
):
result = invocation._run_diffusion(mock_context)
timesteps = clip_timestep_schedule_fractional(torch.linspace(1, 0, invocation.steps + 1).tolist(), 0.25, 0.25)
expected = timesteps[0] * noise + (1.0 - timesteps[0]) * init_latents
assert torch.equal(result, expected)
def test_cogview4_partial_denoise_short_circuit_uses_first_clipped_sigma():
invocation = CogView4DenoiseInvocation.model_construct(
latents=MagicMock(latents_name="latents"),
width=64,
height=64,
steps=4,
denoising_start=0.25,
denoising_end=0.25,
positive_conditioning=MagicMock(conditioning_name="positive"),
negative_conditioning=MagicMock(conditioning_name="negative"),
transformer=MagicMock(transformer="transformer"),
seed=0,
)
init_latents = torch.full((1, 16, 8, 8), 2.0)
noise = torch.full((1, 16, 8, 8), 10.0)
mock_context = MagicMock()
mock_context.tensors.load.return_value = init_latents
transformer_model = MagicMock(config=MagicMock(in_channels=16, patch_size=2))
mock_context.models.load.return_value = MagicMock(model=transformer_model)
with (
patch("invokeai.app.invocations.cogview4_denoise.CogView4Transformer2DModel", object),
patch(
"invokeai.app.invocations.cogview4_denoise.TorchDevice.choose_torch_device",
return_value=torch.device("cpu"),
),
patch.object(invocation, "_prepare_noise_tensor", return_value=noise),
patch.object(invocation, "_load_text_conditioning", return_value=torch.zeros(1, 1, 1)),
):
result = invocation._run_diffusion(mock_context)
timesteps = clip_timestep_schedule_fractional(torch.linspace(1, 0, invocation.steps + 1).tolist(), 0.25, 0.25)
sigmas = invocation._convert_timesteps_to_sigmas(
image_seq_len=((invocation.height // 8) * (invocation.width // 8)) // (2**2),
timesteps=torch.tensor(timesteps),
)
expected = sigmas[0] * noise + (1.0 - sigmas[0]) * init_latents
assert torch.allclose(result, expected, atol=2e-3, rtol=0)
@@ -0,0 +1,111 @@
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from PIL import Image
from invokeai.app.invocations.external_image_generation import OpenAIImageGenerationInvocation
from invokeai.app.invocations.fields import ImageField
from invokeai.app.invocations.model import ModelIdentifierField
from invokeai.app.services.external_generation.external_generation_common import (
ExternalGeneratedImage,
ExternalGenerationResult,
)
from invokeai.app.services.shared.graph import Graph, GraphExecutionState
from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig, ExternalModelCapabilities
def _build_model() -> ExternalApiModelConfig:
return ExternalApiModelConfig(
key="external_test",
name="External Test",
provider_id="openai",
provider_model_id="gpt-image-1",
capabilities=ExternalModelCapabilities(
modes=["txt2img"],
supports_reference_images=True,
supports_seed=True,
),
)
def _build_context(model_config: ExternalApiModelConfig, generated_image: Image.Image) -> MagicMock:
context = MagicMock()
context.models.get_config.return_value = model_config
context.images.get_pil.return_value = generated_image
context.images.save.return_value = SimpleNamespace(image_name="result.png")
context._services.external_generation.generate.return_value = ExternalGenerationResult(
images=[ExternalGeneratedImage(image=generated_image, seed=42)],
provider_request_id="req-123",
provider_metadata={"model": model_config.provider_model_id},
)
return context
def test_external_invocation_builds_request_and_outputs() -> None:
model_config = _build_model()
model_field = ModelIdentifierField.from_config(model_config)
generated_image = Image.new("RGB", (16, 16), color="black")
context = _build_context(model_config, generated_image)
invocation = OpenAIImageGenerationInvocation(
id="external_node",
model=model_field,
mode="txt2img",
prompt="A prompt",
seed=123,
num_images=1,
width=512,
height=512,
reference_images=[ImageField(image_name="ref.png")],
)
output = invocation.invoke(context)
request = context._services.external_generation.generate.call_args[0][0]
assert request.prompt == "A prompt"
assert request.seed == 123
assert len(request.reference_images) == 1
assert output.collection[0].image_name == "result.png"
def test_provider_specific_external_invocation_rejects_wrong_provider() -> None:
model_config = _build_model().model_copy(update={"provider_id": "gemini"})
model_field = ModelIdentifierField.from_config(model_config)
generated_image = Image.new("RGB", (16, 16), color="black")
context = _build_context(model_config, generated_image)
invocation = OpenAIImageGenerationInvocation(
id="external_node",
model=model_field,
mode="txt2img",
prompt="A prompt",
)
with pytest.raises(ValueError, match="does not match node provider"):
invocation.invoke(context)
def test_external_graph_execution_state_runs_node() -> None:
model_config = _build_model()
model_field = ModelIdentifierField.from_config(model_config)
generated_image = Image.new("RGB", (16, 16), color="black")
context = _build_context(model_config, generated_image)
invocation = OpenAIImageGenerationInvocation(
id="external_node",
model=model_field,
mode="txt2img",
prompt="A prompt",
)
graph = Graph()
graph.add_node(invocation)
session = GraphExecutionState(graph=graph)
node = session.next()
assert node is not None
output = node.invoke(context)
session.complete(node.id, output)
assert session.results[node.id] == output
@@ -0,0 +1,62 @@
import pytest
from invokeai.app.invocations.flux_denoise import FluxDenoiseInvocation
TIMESTEPS = [1.0, 0.75, 0.5, 0.25, 0.0]
@pytest.mark.parametrize(
["cfg_scale", "timesteps", "cfg_scale_start_step", "cfg_scale_end_step", "expected"],
[
# Test scalar cfg_scale.
(2.0, TIMESTEPS, 0, -1, [2.0, 2.0, 2.0, 2.0]),
# Test list cfg_scale.
([1.0, 2.0, 3.0, 4.0], TIMESTEPS, 0, -1, [1.0, 2.0, 3.0, 4.0]),
# Test positive cfg_scale_start_step.
(2.0, TIMESTEPS, 1, -1, [1.0, 2.0, 2.0, 2.0]),
# Test positive cfg_scale_end_step.
(2.0, TIMESTEPS, 0, 2, [2.0, 2.0, 2.0, 1.0]),
# Test negative cfg_scale_start_step.
(2.0, TIMESTEPS, -3, -1, [1.0, 2.0, 2.0, 2.0]),
# Test negative cfg_scale_end_step.
(2.0, TIMESTEPS, 0, -2, [2.0, 2.0, 2.0, 1.0]),
# Test single step application.
(2.0, TIMESTEPS, 2, 2, [1.0, 1.0, 2.0, 1.0]),
],
)
def test_prep_cfg_scale(
cfg_scale: float | list[float],
timesteps: list[float],
cfg_scale_start_step: int,
cfg_scale_end_step: int,
expected: list[float],
):
result = FluxDenoiseInvocation.prep_cfg_scale(cfg_scale, timesteps, cfg_scale_start_step, cfg_scale_end_step)
assert result == expected
def test_prep_cfg_scale_invalid_type():
with pytest.raises(ValueError, match="Unsupported cfg_scale type"):
FluxDenoiseInvocation.prep_cfg_scale("invalid", [1.0, 0.5], 0, -1) # type: ignore
@pytest.mark.parametrize("cfg_scale_start_step", [4, -5])
def test_prep_cfg_scale_invalid_start_step(cfg_scale_start_step: int):
with pytest.raises(ValueError, match="Invalid cfg_scale_start_step"):
FluxDenoiseInvocation.prep_cfg_scale(2.0, TIMESTEPS, cfg_scale_start_step, -1)
@pytest.mark.parametrize("cfg_scale_end_step", [4, -5])
def test_prep_cfg_scale_invalid_end_step(cfg_scale_end_step: int):
with pytest.raises(ValueError, match="Invalid cfg_scale_end_step"):
FluxDenoiseInvocation.prep_cfg_scale(2.0, TIMESTEPS, 0, cfg_scale_end_step)
def test_prep_cfg_scale_start_after_end():
with pytest.raises(ValueError, match="cfg_scale_start_step .* must be before cfg_scale_end_step"):
FluxDenoiseInvocation.prep_cfg_scale(2.0, TIMESTEPS, 3, 2)
def test_prep_cfg_scale_list_length_mismatch():
with pytest.raises(AssertionError):
FluxDenoiseInvocation.prep_cfg_scale([1.0, 2.0, 3.0], TIMESTEPS, 0, -1)
+435
View File
@@ -0,0 +1,435 @@
import importlib.util
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
import numpy
import torch
from PIL import Image, ImageFilter
from invokeai.app.invocations.image import ImageField, OklabUnsharpMaskInvocation, OklchImageHueAdjustmentInvocation
from invokeai.app.invocations.primitives import ImageCollectionInvocation
from invokeai.backend.image_util.color_conversion import (
linear_srgb_from_oklab,
linear_srgb_from_oklch,
linear_srgb_from_srgb,
okhsl_from_srgb,
oklab_from_linear_srgb,
oklch_from_oklab,
srgb_from_hsl,
srgb_from_linear_srgb,
srgb_from_okhsl,
)
_COMPOSITION_NODES_SPEC = importlib.util.spec_from_file_location(
"invokeai.app.invocations.composition_nodes",
Path(__file__).resolve().parents[3] / "invokeai/app/invocations/composition-nodes.py",
)
assert _COMPOSITION_NODES_SPEC is not None
assert _COMPOSITION_NODES_SPEC.loader is not None
composition_nodes = importlib.util.module_from_spec(_COMPOSITION_NODES_SPEC)
_COMPOSITION_NODES_SPEC.loader.exec_module(composition_nodes)
InvokeAdjustImageHuePlusInvocation = composition_nodes.InvokeAdjustImageHuePlusInvocation
InvokeImageBlendInvocation = composition_nodes.InvokeImageBlendInvocation
def _build_context(input_image: Image.Image) -> MagicMock:
context = MagicMock()
context.images.get_pil.return_value = input_image
context.images.save.side_effect = lambda image: SimpleNamespace(
image_name="out", width=image.width, height=image.height
)
return context
def _max_abs_diff_uint8(left: Image.Image, right: Image.Image) -> int:
left_arr = numpy.asarray(left, dtype=numpy.int16)
right_arr = numpy.asarray(right, dtype=numpy.int16)
return int(numpy.abs(left_arr - right_arr).max())
def test_image_collection_invocation_preserves_existing_collection_values() -> None:
images = [ImageField(image_name="first"), ImageField(image_name="second")]
output = ImageCollectionInvocation(collection=images).invoke(MagicMock())
assert output.collection == images
def test_image_collection_invocation_appends_direct_images_after_chained_collection() -> None:
chained_images = [ImageField(image_name="chained")]
direct_images = [ImageField(image_name="direct_1"), ImageField(image_name="direct_2")]
output = ImageCollectionInvocation(collection=chained_images, images=direct_images).invoke(MagicMock())
assert output.collection == [*chained_images, *direct_images]
def test_image_collection_invocation_supports_empty_direct_images() -> None:
chained_images = [ImageField(image_name="chained")]
output = ImageCollectionInvocation(collection=chained_images, images=None).invoke(MagicMock())
assert output.collection == chained_images
def test_image_collection_invocation_outputs_empty_collection_when_inputs_are_empty() -> None:
output = ImageCollectionInvocation(collection=None, images=None).invoke(MagicMock())
assert output.collection == []
def test_oklab_unsharp_mask_invocation_preserves_alpha_and_sharpens_lightness_only() -> None:
input_image = Image.new("RGBA", (3, 1))
input_image.putdata(
[
(255, 0, 0, 32),
(0, 255, 0, 128),
(0, 0, 255, 224),
]
)
context = _build_context(input_image)
invocation = OklabUnsharpMaskInvocation(image=ImageField(image_name="in"), radius=1.0, strength=50.0)
output = invocation.invoke(context)
saved_image = context.images.save.call_args.kwargs["image"]
assert output.image.image_name == "out"
assert output.width == 3
assert output.height == 1
assert numpy.asarray(saved_image.getchannel("A")).reshape(-1).tolist() == [32, 128, 224]
rgb = torch.from_numpy(numpy.asarray(input_image.convert("RGB"), dtype=numpy.float32) / 255.0).permute(2, 0, 1)
blurred_rgb = torch.from_numpy(
numpy.asarray(input_image.convert("RGB").filter(ImageFilter.GaussianBlur(radius=1.0)), dtype=numpy.float32)
/ 255.0
).permute(2, 0, 1)
rgb_unsharp = torch.clamp(rgb + (rgb - blurred_rgb) * 0.5, 0.0, 1.0)
rgb_oklab = oklab_from_linear_srgb(linear_srgb_from_srgb(rgb))
blurred_oklab = oklab_from_linear_srgb(linear_srgb_from_srgb(blurred_rgb))
expected_oklab = rgb_oklab.clone()
expected_oklab[0, ...] = torch.clamp(
rgb_oklab[0, ...] + (rgb_oklab[0, ...] - blurred_oklab[0, ...]) * 0.5,
-1.0,
1.0,
)
oklab_unsharp = srgb_from_linear_srgb(linear_srgb_from_oklab(expected_oklab))
assert not torch.allclose(oklab_unsharp, rgb_unsharp, atol=1e-3)
assert numpy.allclose(
numpy.asarray(saved_image.convert("RGB"), dtype=numpy.float32) / 255.0,
oklab_unsharp.permute(1, 2, 0).numpy(),
atol=1 / 255.0,
)
def test_oklch_hue_adjustment_invocation_preserves_alpha_and_rotates_hue_in_oklch() -> None:
input_image = Image.new("RGBA", (2, 1))
input_image.putdata(
[
(210, 80, 30, 64),
(40, 160, 220, 192),
]
)
context = _build_context(input_image)
invocation = OklchImageHueAdjustmentInvocation(image=ImageField(image_name="in"), hue=180)
output = invocation.invoke(context)
saved_image = context.images.save.call_args.kwargs["image"]
rgb = torch.from_numpy(numpy.asarray(input_image.convert("RGB"), dtype=numpy.float32) / 255.0).permute(2, 0, 1)
oklch = oklch_from_oklab(oklab_from_linear_srgb(linear_srgb_from_srgb(rgb)))
rotated_oklch = oklch.clone()
rotated_oklch[2, ...] = (rotated_oklch[2, ...] + 180.0) % 360.0
expected_rgb = srgb_from_linear_srgb(linear_srgb_from_oklch(rotated_oklch))
assert output.image.image_name == "out"
assert output.width == 2
assert output.height == 1
assert numpy.asarray(saved_image.getchannel("A")).reshape(-1).tolist() == [64, 192]
assert numpy.allclose(
numpy.asarray(saved_image.convert("RGB"), dtype=numpy.float32) / 255.0,
expected_rgb.permute(1, 2, 0).numpy(),
atol=1 / 255.0,
)
def test_oklab_unsharp_mask_invocation_zero_strength_returns_original_image() -> None:
input_image = Image.new("RGBA", (2, 2))
input_image.putdata(
[
(12, 34, 56, 78),
(90, 123, 45, 67),
(210, 40, 80, 90),
(255, 200, 10, 255),
]
)
context = _build_context(input_image)
invocation = OklabUnsharpMaskInvocation(image=ImageField(image_name="in"), radius=1.5, strength=0.0)
invocation.invoke(context)
saved_image = context.images.save.call_args.kwargs["image"]
assert _max_abs_diff_uint8(saved_image, input_image) <= 1
def test_oklab_unsharp_mask_invocation_does_not_introduce_color_on_grayscale_image() -> None:
input_image = Image.new("RGB", (3, 1))
input_image.putdata([(32, 32, 32), (128, 128, 128), (224, 224, 224)])
context = _build_context(input_image)
invocation = OklabUnsharpMaskInvocation(image=ImageField(image_name="in"), radius=1.0, strength=80.0)
invocation.invoke(context)
saved_image = context.images.save.call_args.kwargs["image"]
saved_rgb = numpy.asarray(saved_image.convert("RGB"), dtype=numpy.uint8)
assert numpy.abs(saved_rgb[..., 0].astype(numpy.int16) - saved_rgb[..., 1].astype(numpy.int16)).max() <= 1
assert numpy.abs(saved_rgb[..., 1].astype(numpy.int16) - saved_rgb[..., 2].astype(numpy.int16)).max() <= 1
def test_oklab_unsharp_mask_invocation_clips_extreme_values_to_valid_rgb_range() -> None:
input_image = Image.new("RGB", (3, 1))
input_image.putdata([(255, 255, 255), (0, 0, 0), (255, 255, 255)])
context = _build_context(input_image)
invocation = OklabUnsharpMaskInvocation(image=ImageField(image_name="in"), radius=2.0, strength=500.0)
invocation.invoke(context)
saved_rgb = numpy.asarray(context.images.save.call_args.kwargs["image"].convert("RGB"), dtype=numpy.uint8)
assert saved_rgb.min() >= 0
assert saved_rgb.max() <= 255
def test_oklch_hue_adjustment_invocation_wraps_hue_values_and_supports_rgb_input() -> None:
input_image = Image.new("RGB", (2, 1))
input_image.putdata([(210, 80, 30), (40, 160, 220)])
base_context = _build_context(input_image)
zero_output = OklchImageHueAdjustmentInvocation(image=ImageField(image_name="in"), hue=0).invoke(base_context)
zero_saved = base_context.images.save.call_args.kwargs["image"]
full_turn_context = _build_context(input_image)
full_turn_output = OklchImageHueAdjustmentInvocation(image=ImageField(image_name="in"), hue=360).invoke(
full_turn_context
)
full_turn_saved = full_turn_context.images.save.call_args.kwargs["image"]
negative_context = _build_context(input_image)
OklchImageHueAdjustmentInvocation(image=ImageField(image_name="in"), hue=-180).invoke(negative_context)
negative_saved = negative_context.images.save.call_args.kwargs["image"]
positive_context = _build_context(input_image)
OklchImageHueAdjustmentInvocation(image=ImageField(image_name="in"), hue=180).invoke(positive_context)
positive_saved = positive_context.images.save.call_args.kwargs["image"]
assert zero_output.width == 2
assert zero_output.height == 1
assert full_turn_output.width == 2
assert full_turn_output.height == 1
assert _max_abs_diff_uint8(zero_saved, input_image) <= 1
assert _max_abs_diff_uint8(full_turn_saved, input_image) <= 1
assert _max_abs_diff_uint8(negative_saved, positive_saved) <= 1
def test_new_oklab_nodes_preserve_alpha_for_non_rgba_alpha_modes() -> None:
la_image = Image.new("LA", (2, 1))
la_image.putdata([(32, 64), (192, 224)])
unsharp_context = _build_context(la_image)
OklabUnsharpMaskInvocation(image=ImageField(image_name="in"), radius=1.0, strength=25.0).invoke(unsharp_context)
unsharp_saved = unsharp_context.images.save.call_args.kwargs["image"]
hue_context = _build_context(la_image)
OklchImageHueAdjustmentInvocation(image=ImageField(image_name="in"), hue=45).invoke(hue_context)
hue_saved = hue_context.images.save.call_args.kwargs["image"]
assert unsharp_saved.mode == "LA"
assert hue_saved.mode == "LA"
assert numpy.asarray(unsharp_saved.getchannel("A")).reshape(-1).tolist() == [64, 224]
assert numpy.asarray(hue_saved.getchannel("A")).reshape(-1).tolist() == [64, 224]
def test_hue_adjust_plus_oklch_uses_degree_based_oklch_contract() -> None:
input_image = Image.new("RGB", (2, 1))
input_image.putdata([(210, 80, 30), (40, 160, 220)])
context = _build_context(input_image)
invocation = InvokeAdjustImageHuePlusInvocation(
image=ImageField(image_name="in"),
space="*Oklch / Oklab",
degrees=180.0,
ok_adaptive_gamut=0.0,
)
output = invocation.invoke(context)
saved_image = context.images.save.call_args.args[0]
rgb = torch.from_numpy(numpy.asarray(input_image, dtype=numpy.float32) / 255.0).permute(2, 0, 1)
oklch = oklch_from_oklab(oklab_from_linear_srgb(linear_srgb_from_srgb(rgb)))
rotated_oklch = oklch.clone()
rotated_oklch[2, ...] = (rotated_oklch[2, ...] + 180.0) % 360.0
expected_rgb = srgb_from_linear_srgb(linear_srgb_from_oklch(rotated_oklch))
assert output.width == 2
assert output.height == 1
assert numpy.allclose(
numpy.asarray(saved_image.convert("RGB"), dtype=numpy.float32) / 255.0,
expected_rgb.permute(1, 2, 0).numpy(),
atol=1 / 255.0,
)
def test_hue_adjust_plus_hsv_uses_degree_hue_contract() -> None:
input_image = Image.new("RGB", (2, 1))
input_image.putdata([(210, 80, 30), (40, 160, 220)])
context = _build_context(input_image)
invocation = InvokeAdjustImageHuePlusInvocation(
image=ImageField(image_name="in"),
space="HSV / HSL / RGB",
degrees=90.0,
)
output = invocation.invoke(context)
saved_image = context.images.save.call_args.args[0]
hsv = numpy.asarray(input_image.convert("HSV"), dtype=numpy.float32) / 255.0
hsv[..., 0] = ((hsv[..., 0] * 360.0) + 90.0) % 360.0 / 360.0
expected_rgb = Image.fromarray((hsv * 255.0).astype(numpy.uint8), mode="HSV").convert("RGB")
assert output.width == 2
assert output.height == 1
assert _max_abs_diff_uint8(saved_image.convert("RGB"), expected_rgb) <= 1
def test_hue_adjust_plus_okhsl_uses_degree_hue_contract() -> None:
input_image = Image.new("RGB", (2, 1))
input_image.putdata([(210, 80, 30), (40, 160, 220)])
context = _build_context(input_image)
invocation = InvokeAdjustImageHuePlusInvocation(
image=ImageField(image_name="in"),
space="Okhsl",
degrees=90.0,
ok_adaptive_gamut=0.0,
)
output = invocation.invoke(context)
saved_image = context.images.save.call_args.args[0]
rgb = torch.from_numpy(numpy.asarray(input_image, dtype=numpy.float32) / 255.0).permute(2, 0, 1)
okhsl = okhsl_from_srgb(rgb)
rotated_okhsl = okhsl.clone()
rotated_okhsl[0, ...] = (rotated_okhsl[0, ...] + 90.0) % 360.0
expected_rgb = srgb_from_okhsl(rotated_okhsl)
assert output.width == 2
assert output.height == 1
assert numpy.allclose(
numpy.asarray(saved_image.convert("RGB"), dtype=numpy.float32) / 255.0,
expected_rgb.permute(1, 2, 0).numpy(),
atol=1 / 255.0,
)
def test_image_blend_oklch_subtract_wraps_hue_in_degrees() -> None:
invocation = InvokeImageBlendInvocation(
layer_upper=ImageField(image_name="upper"),
layer_base=ImageField(image_name="base"),
blend_mode="Subtract",
color_space="Oklch (Oklab)",
opacity=1.0,
adaptive_gamut=0.0,
)
upper_oklch = torch.tensor([[[0.0]], [[0.0]], [[20.0]]], dtype=torch.float32)
lower_oklch = torch.tensor([[[0.6]], [[0.18]], [[350.0]]], dtype=torch.float32)
expected_linear_srgb = linear_srgb_from_oklch(torch.tensor([[[0.6]], [[0.18]], [[330.0]]], dtype=torch.float32))
blank_rgb = torch.zeros((3, 1, 1), dtype=torch.float32)
blank_alpha = torch.ones((1, 1), dtype=torch.float32)
image_tensors = (
blank_rgb,
blank_rgb,
blank_rgb,
blank_rgb,
blank_alpha,
blank_alpha,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
upper_oklch,
lower_oklch,
None,
None,
None,
None,
)
blended = invocation.apply_blend(image_tensors)
assert torch.allclose(blended, expected_linear_srgb, atol=1e-5)
def test_image_blend_hsl_subtract_wraps_hue_in_degrees() -> None:
invocation = InvokeImageBlendInvocation(
layer_upper=ImageField(image_name="upper"),
layer_base=ImageField(image_name="base"),
blend_mode="Subtract",
color_space="HSL (RGB)",
opacity=1.0,
adaptive_gamut=0.0,
)
upper_hsl = torch.tensor([[[20.0]], [[0.0]], [[0.0]]], dtype=torch.float32)
lower_hsl = torch.tensor([[[350.0]], [[1.0]], [[0.5]]], dtype=torch.float32)
expected_linear_srgb = linear_srgb_from_srgb(
srgb_from_hsl(torch.tensor([[[330.0]], [[1.0]], [[0.5]]], dtype=torch.float32))
)
blank_rgb = torch.zeros((3, 1, 1), dtype=torch.float32)
blank_alpha = torch.ones((1, 1), dtype=torch.float32)
image_tensors = (
blank_rgb,
blank_rgb,
blank_rgb,
blank_rgb,
blank_alpha,
blank_alpha,
None,
None,
None,
upper_hsl,
lower_hsl,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
)
blended = invocation.apply_blend(image_tensors)
assert torch.allclose(blended, expected_linear_srgb, atol=1e-5)
+46
View File
@@ -0,0 +1,46 @@
from typing import Any, Literal, Optional, Union
import pytest
from pydantic import BaseModel
class TestModel(BaseModel):
foo: Literal["bar"] = "bar"
@pytest.mark.parametrize(
"input_type, expected",
[
(str, False),
(list[str], False),
(list[dict[str, Any]], False),
(list[None], False),
(list[dict[str, None]], False),
(Any, False),
(True, False),
(False, False),
(Union[str, False], False),
(Union[str, True], False),
(None, False),
(str | None, True),
(Union[str, None], True),
(Optional[str], True),
(str | int | None, True),
(None | str | int, True),
(Union[None, str], True),
(Optional[str], True),
(Optional[int], True),
(Optional[str], True),
(TestModel | None, True),
(Union[TestModel, None], True),
(Optional[TestModel], True),
],
)
def test_is_optional(input_type: Any, expected: bool) -> None:
"""
Test the is_optional function.
"""
from invokeai.app.invocations.baseinvocation import is_optional
result = is_optional(input_type)
assert result == expected, f"Expected {expected} but got {result} for input type {input_type}"
+101
View File
@@ -0,0 +1,101 @@
from unittest.mock import MagicMock
import pytest
import torch
@pytest.mark.parametrize(
("noise_type", "width", "height", "expected_shape"),
[
("SD", 64, 64, (1, 4, 8, 8)),
("FLUX", 64, 64, (1, 16, 8, 8)),
("FLUX.2", 64, 64, (1, 32, 8, 8)),
("SD3", 64, 64, (1, 16, 8, 8)),
("CogView4", 64, 64, (1, 16, 8, 8)),
("Z-Image", 64, 64, (1, 16, 8, 8)),
("Anima", 64, 64, (1, 16, 1, 8, 8)),
],
)
def test_noise_invocation_generates_expected_shapes(noise_type: str, width: int, height: int, expected_shape):
from invokeai.app.invocations.noise import NoiseInvocation
mock_context = MagicMock()
mock_context.tensors.save.return_value = "noise-name"
invocation = NoiseInvocation(noise_type=noise_type, width=width, height=height, seed=123)
output = invocation.invoke(mock_context)
saved_tensor = mock_context.tensors.save.call_args.kwargs["tensor"]
assert saved_tensor.shape == expected_shape
assert output.noise.seed == 123
assert output.width == width
assert output.height == height
def test_noise_invocation_defaults_to_sd_shape():
from invokeai.app.invocations.noise import NoiseInvocation
mock_context = MagicMock()
mock_context.tensors.save.return_value = "noise-name"
invocation = NoiseInvocation(width=64, height=64, seed=1)
invocation.invoke(mock_context)
saved_tensor = mock_context.tensors.save.call_args.kwargs["tensor"]
assert saved_tensor.shape == (1, 4, 8, 8)
@pytest.mark.parametrize(
("noise_type", "width", "height", "message"),
[
("SD", 66, 64, "multiple of 8"),
("FLUX", 72, 64, "multiple of 16"),
("FLUX.2", 64, 72, "multiple of 16"),
("SD3", 72, 64, "multiple of 16"),
("Z-Image", 64, 72, "multiple of 16"),
("CogView4", 64, 80, "multiple of 32"),
("Anima", 66, 64, "multiple of 8"),
],
)
def test_noise_invocation_rejects_invalid_dimensions(noise_type: str, width: int, height: int, message: str):
from invokeai.app.invocations.noise import NoiseInvocation
mock_context = MagicMock()
with pytest.raises(ValueError, match=message):
invocation = NoiseInvocation(noise_type=noise_type, width=width, height=height, seed=0)
invocation.invoke(mock_context)
def test_noise_invocation_is_deterministic_for_identical_inputs():
from invokeai.app.invocations.noise import NoiseInvocation
mock_context = MagicMock()
mock_context.tensors.save.side_effect = ["noise-1", "noise-2"]
invocation = NoiseInvocation(noise_type="FLUX", width=64, height=64, seed=7)
invocation.invoke(mock_context)
first = mock_context.tensors.save.call_args_list[0].kwargs["tensor"]
invocation.invoke(mock_context)
second = mock_context.tensors.save.call_args_list[1].kwargs["tensor"]
assert torch.equal(first, second)
@pytest.mark.parametrize(("noise_type", "expected_shape"), [("FLUX", (1, 16, 8, 8)), ("FLUX.2", (1, 32, 8, 8))])
def test_generate_noise_tensor_honors_use_cpu_false_for_flux_variants(noise_type: str, expected_shape):
from invokeai.app.invocations.latent_noise import generate_noise_tensor
noise = generate_noise_tensor(
noise_type=noise_type,
width=64,
height=64,
seed=0,
device=torch.device("cpu"),
dtype=torch.float32,
use_cpu=False,
)
assert noise.shape == expected_shape
@@ -0,0 +1,197 @@
"""Tests for the Qwen Image denoise invocation."""
import pytest
from invokeai.app.invocations.qwen_image_denoise import QwenImageDenoiseInvocation
class TestPrepareCfgScale:
"""Test _prepare_cfg_scale utility method."""
def test_scalar_cfg_scale(self):
inv = QwenImageDenoiseInvocation.model_construct(cfg_scale=4.0)
result = inv._prepare_cfg_scale(5)
assert result == [4.0, 4.0, 4.0, 4.0, 4.0]
def test_list_cfg_scale(self):
inv = QwenImageDenoiseInvocation.model_construct(cfg_scale=[1.0, 2.0, 3.0])
result = inv._prepare_cfg_scale(3)
assert result == [1.0, 2.0, 3.0]
def test_list_cfg_scale_length_mismatch(self):
inv = QwenImageDenoiseInvocation.model_construct(cfg_scale=[1.0, 2.0])
with pytest.raises(AssertionError):
inv._prepare_cfg_scale(3)
def test_invalid_cfg_scale_type(self):
inv = QwenImageDenoiseInvocation.model_construct(cfg_scale="invalid")
with pytest.raises(ValueError, match="Invalid CFG scale type"):
inv._prepare_cfg_scale(3)
class TestPackUnpackLatents:
"""Test latent packing and unpacking roundtrip."""
def test_pack_unpack_roundtrip(self):
"""Packing then unpacking should restore the original tensor."""
import torch
latents = torch.randn(1, 16, 128, 128)
packed = QwenImageDenoiseInvocation._pack_latents(latents, 1, 16, 128, 128)
assert packed.shape == (1, 64 * 64, 64) # (B, H/2*W/2, C*4)
unpacked = QwenImageDenoiseInvocation._unpack_latents(packed, 128, 128)
assert unpacked.shape == (1, 16, 128, 128)
assert torch.allclose(latents, unpacked)
def test_pack_shape(self):
"""Pack should produce the correct shape."""
import torch
latents = torch.randn(1, 16, 140, 118)
packed = QwenImageDenoiseInvocation._pack_latents(latents, 1, 16, 140, 118)
assert packed.shape == (1, 70 * 59, 64)
def test_unpack_shape(self):
"""Unpack should produce the correct shape."""
import torch
packed = torch.randn(1, 70 * 59, 64)
unpacked = QwenImageDenoiseInvocation._unpack_latents(packed, 140, 118)
assert unpacked.shape == (1, 16, 140, 118)
class TestAlignRefLatentDims:
"""Test reference latent dim alignment for 2x2 packing."""
def test_even_dims_unchanged(self):
assert QwenImageDenoiseInvocation._align_ref_latent_dims(96, 64) == (96, 64)
def test_odd_dims_trimmed_to_even(self):
assert QwenImageDenoiseInvocation._align_ref_latent_dims(97, 65) == (96, 64)
assert QwenImageDenoiseInvocation._align_ref_latent_dims(150, 151) == (150, 150)
def test_minimum_aligned_dims(self):
assert QwenImageDenoiseInvocation._align_ref_latent_dims(2, 2) == (2, 2)
assert QwenImageDenoiseInvocation._align_ref_latent_dims(3, 2) == (2, 2)
def test_raises_on_zero_dim(self):
with pytest.raises(ValueError, match="spatial dims must be >= 2"):
QwenImageDenoiseInvocation._align_ref_latent_dims(0, 64)
with pytest.raises(ValueError, match="spatial dims must be >= 2"):
QwenImageDenoiseInvocation._align_ref_latent_dims(64, 0)
def test_raises_on_one_dim(self):
"""A 1-pixel latent aligns to 0 and must be rejected."""
with pytest.raises(ValueError, match="spatial dims must be >= 2"):
QwenImageDenoiseInvocation._align_ref_latent_dims(1, 64)
with pytest.raises(ValueError, match="spatial dims must be >= 2"):
QwenImageDenoiseInvocation._align_ref_latent_dims(64, 1)
class TestMaybeClampRefLatentSize:
"""Test the diffusers-style VAE_IMAGE_SIZE clamp applied to reference latents
before packing. This is defense-in-depth for backend callers (direct API,
older graph JSON) that wire qwen_image_i2l without explicit width/height —
without the clamp, the transformer receives an out-of-distribution sequence
length and VRAM usage spikes on large reference images."""
def test_in_budget_latent_unchanged(self):
"""A 1024² ref image → 128x128 latent → exactly the budget. Pass through."""
import torch
ref = torch.randn(1, 16, 128, 128)
result = QwenImageDenoiseInvocation._maybe_clamp_ref_latent_size(ref)
assert result.shape == (1, 16, 128, 128)
assert result is ref # identity, no copy
def test_small_latent_unchanged(self):
"""A 512² ref → 64x64 latent (4x under budget). Pass through unchanged."""
import torch
ref = torch.randn(1, 16, 64, 64)
result = QwenImageDenoiseInvocation._maybe_clamp_ref_latent_size(ref)
assert result.shape == (1, 16, 64, 64)
assert result is ref
def test_native_resolution_landscape_clamped(self):
"""A native 1600x1200 image → 200x150 latents. Should clamp to the same
dims diffusers produces (1184x896 pixels → 148x112 latents)."""
import torch
ref = torch.randn(1, 16, 150, 200)
result = QwenImageDenoiseInvocation._maybe_clamp_ref_latent_size(ref)
assert result.shape == (1, 16, 112, 148)
def test_native_resolution_portrait_clamped(self):
"""1200x1600 → 150x200 latents → diffusers target 896x1184 → 112x148."""
import torch
ref = torch.randn(1, 16, 200, 150)
result = QwenImageDenoiseInvocation._maybe_clamp_ref_latent_size(ref)
assert result.shape == (1, 16, 148, 112)
def test_huge_latent_clamped(self):
"""A 4096x4096 image → 512x512 latents (16x budget). Clamp to 128x128
latents (= 1024² pixels), well within model's trained distribution."""
import torch
ref = torch.randn(1, 16, 512, 512)
result = QwenImageDenoiseInvocation._maybe_clamp_ref_latent_size(ref)
assert result.shape == (1, 16, 128, 128)
def test_clamp_preserves_aspect_ratio_within_rounding(self):
"""Aspect ratio of the clamped latent should match the input to within
the 32-pixel snapping granularity used by diffusers."""
import torch
# 1920x1080 (16:9, ~2M pixels)
ref = torch.randn(1, 16, 135, 240)
result = QwenImageDenoiseInvocation._maybe_clamp_ref_latent_size(ref)
# diffusers: calculate_dimensions(1024², 16/9) → (1376, 768) px → (172, 96) latent
assert result.shape == (1, 16, 96, 172)
def test_clamp_output_is_packable(self):
"""The clamped latent must have even spatial dims (required by 2x2 packing)
before _align_ref_latent_dims is called. Because the clamp snaps to 32px
in pixel space and vae_scale_factor=8, every clamp output is a multiple
of 4 in latent space (and therefore even)."""
import torch
for h, w in [(150, 200), (200, 150), (135, 240), (512, 512)]:
ref = torch.randn(1, 16, h, w)
result = QwenImageDenoiseInvocation._maybe_clamp_ref_latent_size(ref)
_, _, rh, rw = result.shape
assert rh % 2 == 0, f"clamp produced odd height {rh} for input ({h},{w})"
assert rw % 2 == 0, f"clamp produced odd width {rw} for input ({h},{w})"
class TestBuildImgShapes:
"""Test img_shapes construction. Regression test for the ghosting/doubling bug
where ref and noisy segments shared identical spatial RoPE positions."""
def test_txt2img_single_segment(self):
"""No reference latent → single segment for the noisy latent only."""
result = QwenImageDenoiseInvocation._build_img_shapes(64, 64)
assert result == [[(1, 32, 32)]]
def test_edit_uses_distinct_ref_dims(self):
"""Edit-mode img_shapes must place ref segment at the ref's OWN dims, not
the noisy dims. Identical dims caused the ghosting artifact."""
noisy_h, noisy_w = 64, 64
ref_h, ref_w = 96, 64
result = QwenImageDenoiseInvocation._build_img_shapes(noisy_h, noisy_w, ref_h, ref_w)
assert result == [[(1, 32, 32), (1, 48, 32)]]
# The bug was that both segments had the same shape:
assert result[0][0] != result[0][1]
def test_edit_matches_diffusers_layout(self):
"""Structure must match diffusers QwenImageEditPipeline (single batch,
nested list of (frame, h//2, w//2) tuples)."""
result = QwenImageDenoiseInvocation._build_img_shapes(80, 112, 128, 96)
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], list)
assert len(result[0]) == 2
assert result[0][0] == (1, 40, 56)
assert result[0][1] == (1, 64, 48)
@@ -0,0 +1,113 @@
"""Tests for the Qwen Image model loader invocation."""
from unittest.mock import MagicMock
import pytest
from invokeai.app.invocations.model import ModelIdentifierField
from invokeai.app.invocations.qwen_image_model_loader import QwenImageModelLoaderInvocation
from invokeai.backend.model_manager.taxonomy import ModelFormat, SubModelType
def _make_model_id(**kwargs) -> ModelIdentifierField:
defaults = {"key": "test-key", "hash": "test-hash", "name": "test", "base": "qwen-image", "type": "main"}
defaults.update(kwargs)
return ModelIdentifierField(**defaults)
def _make_mock_context(
main_format: ModelFormat = ModelFormat.Diffusers, source_format: ModelFormat = ModelFormat.Diffusers
):
"""Create a mock InvocationContext that returns configs with the given formats."""
context = MagicMock()
def get_config(model_id):
config = MagicMock()
if model_id.key == "main-key":
config.format = main_format
config.name = "Main Model"
elif model_id.key == "source-key":
config.format = source_format
config.name = "Source Model"
return config
context.models.get_config = get_config
context.models.exists = MagicMock(return_value=True)
return context
class TestDiffusersModel:
"""Tests for loading a Diffusers-format Qwen Image model."""
def test_diffusers_model_extracts_all_components(self):
"""A Diffusers model should extract transformer, VAE, tokenizer, and text encoder from itself."""
model_id = _make_model_id(key="main-key")
inv = QwenImageModelLoaderInvocation.model_construct(model=model_id, component_source=None)
context = _make_mock_context(main_format=ModelFormat.Diffusers)
result = inv.invoke(context)
assert result.transformer.transformer.submodel_type == SubModelType.Transformer
assert result.vae.vae.submodel_type == SubModelType.VAE
assert result.qwen_vl_encoder.tokenizer.submodel_type == SubModelType.Tokenizer
assert result.qwen_vl_encoder.text_encoder.submodel_type == SubModelType.TextEncoder
# All should reference the main model key
assert result.transformer.transformer.key == "main-key"
assert result.vae.vae.key == "main-key"
assert result.qwen_vl_encoder.tokenizer.key == "main-key"
assert result.qwen_vl_encoder.text_encoder.key == "main-key"
def test_diffusers_model_ignores_component_source(self):
"""A Diffusers model should ignore the component_source even if provided."""
model_id = _make_model_id(key="main-key")
source_id = _make_model_id(key="source-key")
inv = QwenImageModelLoaderInvocation.model_construct(model=model_id, component_source=source_id)
context = _make_mock_context(main_format=ModelFormat.Diffusers)
result = inv.invoke(context)
# All components should come from main, not source
assert result.vae.vae.key == "main-key"
assert result.qwen_vl_encoder.tokenizer.key == "main-key"
class TestGGUFModel:
"""Tests for loading a GGUF-format Qwen Image model."""
def test_gguf_with_component_source_succeeds(self):
"""A GGUF model with a Diffusers component source should load successfully."""
model_id = _make_model_id(key="main-key")
source_id = _make_model_id(key="source-key")
inv = QwenImageModelLoaderInvocation.model_construct(model=model_id, component_source=source_id)
context = _make_mock_context(main_format=ModelFormat.GGUFQuantized, source_format=ModelFormat.Diffusers)
result = inv.invoke(context)
# Transformer from main model
assert result.transformer.transformer.key == "main-key"
assert result.transformer.transformer.submodel_type == SubModelType.Transformer
# VAE and encoder from component source
assert result.vae.vae.key == "source-key"
assert result.qwen_vl_encoder.tokenizer.key == "source-key"
assert result.qwen_vl_encoder.text_encoder.key == "source-key"
def test_gguf_without_component_source_raises(self):
"""A GGUF model without a component source should raise ValueError."""
model_id = _make_model_id(key="main-key")
inv = QwenImageModelLoaderInvocation.model_construct(model=model_id, component_source=None)
context = _make_mock_context(main_format=ModelFormat.GGUFQuantized)
with pytest.raises(ValueError, match="No source for VAE"):
inv.invoke(context)
def test_gguf_with_non_diffusers_source_raises(self):
"""A GGUF model with a non-Diffusers component source should raise ValueError."""
model_id = _make_model_id(key="main-key")
source_id = _make_model_id(key="source-key")
inv = QwenImageModelLoaderInvocation.model_construct(model=model_id, component_source=source_id)
context = _make_mock_context(main_format=ModelFormat.GGUFQuantized, source_format=ModelFormat.GGUFQuantized)
with pytest.raises(ValueError, match="Component Source model must be in Diffusers format"):
inv.invoke(context)
@@ -0,0 +1,124 @@
"""Tests for the Qwen Image text encoder prompt building and image resizing."""
from PIL import Image
from invokeai.app.invocations.qwen_image_text_encoder import (
QwenImageTextEncoderInvocation,
_build_prompt,
)
class TestBuildPrompt:
"""Test the _build_prompt function for edit vs generate modes."""
def test_no_images_uses_generate_template(self):
"""With 0 images, should use the generate (txt2img) template with no vision placeholder."""
prompt = _build_prompt("a beautiful sunset", 0)
assert "a beautiful sunset" in prompt
assert "<|im_start|>assistant" in prompt
# Generate mode: no vision placeholders, uses the "describe the image" system prompt
assert "<|vision_start|>" not in prompt
assert "Describe the image by detailing" in prompt
def test_no_images_does_not_use_edit_template(self):
"""With 0 images, should NOT use the edit system prompt."""
prompt = _build_prompt("a beautiful sunset", 0)
assert "Describe the key features of the input image" not in prompt
def test_edit_mode_one_image(self):
"""With 1 image, should use the edit template with one vision placeholder."""
prompt = _build_prompt("change hair to red", 1)
assert "Describe the key features of the input image" in prompt
assert prompt.count("<|vision_start|><|image_pad|><|vision_end|>") == 1
assert "change hair to red" in prompt
# Should NOT use the generate system prompt
assert "Describe the image by detailing" not in prompt
def test_edit_mode_multiple_images(self):
"""With multiple images, should include one placeholder per image."""
prompt = _build_prompt("combine these images", 3)
assert prompt.count("<|vision_start|><|image_pad|><|vision_end|>") == 3
assert "combine these images" in prompt
def test_generate_template_has_correct_structure(self):
"""Generate template should have system + user + assistant roles."""
prompt = _build_prompt("test prompt", 0)
assert prompt.startswith("<|im_start|>system\n")
assert "<|im_end|>\n<|im_start|>user\n" in prompt
assert prompt.endswith("<|im_start|>assistant\n")
def test_edit_template_has_correct_structure(self):
"""Edit template should have system + user (with image) + assistant roles."""
prompt = _build_prompt("test prompt", 1)
assert prompt.startswith("<|im_start|>system\n")
assert "<|im_end|>\n<|im_start|>user\n" in prompt
assert "<|vision_start|>" in prompt
assert prompt.endswith("<|im_start|>assistant\n")
def test_prompt_special_characters(self):
"""Prompt with special characters should be included verbatim."""
prompt = _build_prompt("add {curly} braces & <angle> brackets", 0)
assert "add {curly} braces & <angle> brackets" in prompt
class TestResizeForVLEncoder:
"""Test the image resizing logic for the VL encoder."""
def test_large_image_is_resized(self):
"""A large image should be resized to ~target_pixels."""
img = Image.new("RGB", (2048, 2048))
resized = QwenImageTextEncoderInvocation._resize_for_vl_encoder(img, target_pixels=512 * 512)
w, h = resized.size
# Should be much smaller than original
assert w < 2048
assert h < 2048
# Total pixels should be approximately target
assert abs(w * h - 512 * 512) < 10000 # within ~10k pixels
def test_small_image_is_resized(self):
"""A small image should also be resized to ~target_pixels."""
img = Image.new("RGB", (64, 64))
resized = QwenImageTextEncoderInvocation._resize_for_vl_encoder(img, target_pixels=512 * 512)
w, h = resized.size
# Should be larger than original
assert w > 64
assert h > 64
def test_aspect_ratio_preserved(self):
"""Aspect ratio should be approximately preserved."""
img = Image.new("RGB", (800, 400)) # 2:1 aspect ratio
resized = QwenImageTextEncoderInvocation._resize_for_vl_encoder(img, target_pixels=512 * 512)
w, h = resized.size
original_ratio = 800 / 400 # 2.0
new_ratio = w / h
# Allow some deviation due to rounding to multiples of 32
assert abs(new_ratio - original_ratio) < 0.3
def test_dimensions_are_multiples_of_32(self):
"""Output dimensions should be multiples of 32."""
img = Image.new("RGB", (1000, 750))
resized = QwenImageTextEncoderInvocation._resize_for_vl_encoder(img, target_pixels=512 * 512)
w, h = resized.size
assert w % 32 == 0
assert h % 32 == 0
def test_square_image(self):
"""A square image should produce approximately square output."""
img = Image.new("RGB", (1024, 1024))
resized = QwenImageTextEncoderInvocation._resize_for_vl_encoder(img, target_pixels=512 * 512)
w, h = resized.size
assert abs(w - h) <= 32 # within one grid step
def test_portrait_image(self):
"""A portrait image should produce portrait output."""
img = Image.new("RGB", (600, 1200))
resized = QwenImageTextEncoderInvocation._resize_for_vl_encoder(img, target_pixels=512 * 512)
w, h = resized.size
assert h > w # should remain portrait
def test_landscape_image(self):
"""A landscape image should produce landscape output."""
img = Image.new("RGB", (1200, 600))
resized = QwenImageTextEncoderInvocation._resize_for_vl_encoder(img, target_pixels=512 * 512)
w, h = resized.size
assert w > h # should remain landscape
@@ -0,0 +1,136 @@
"""Test that Qwen Image VAE invocations properly estimate and request working memory."""
from contextlib import nullcontext
from unittest.mock import MagicMock, patch
import pytest
import torch
from diffusers.models.autoencoders.autoencoder_kl_qwenimage import AutoencoderKLQwenImage
from invokeai.app.invocations.qwen_image_image_to_latents import QwenImageImageToLatentsInvocation
from invokeai.app.invocations.qwen_image_latents_to_image import QwenImageLatentsToImageInvocation
from invokeai.backend.util.vae_working_memory import estimate_vae_working_memory_qwen_image
class TestQwenImageWorkingMemoryEstimate:
"""Lock in the per-backend scaling constants calibrated in scripts/calibrate_qwen_vae_working_memory.py.
These differ by backend because the Qwen VAE is attention-heavy: ROCm falls back to math attention
(O(area^2), much higher memory) while CUDA uses Flash/efficient attention. A regression that swaps
the constants would reintroduce the ROCm OOM (under-estimate) or needlessly over-budget CUDA.
"""
# (operation, latent_h, latent_w) -> the estimator scales pixel area (latent * 8 for decode,
# raw for encode) by element_size and the constant.
@pytest.mark.parametrize(
"operation, is_rocm, expected_constant",
[
("decode", True, 5500),
("decode", False, 2900),
("encode", True, 6300),
("encode", False, 1600),
],
)
def test_constant_selected_per_backend(self, operation, is_rocm, expected_constant):
mock_vae = MagicMock(spec=AutoencoderKLQwenImage)
mock_vae.parameters.return_value = iter([torch.zeros(1, dtype=torch.float16)]) # element_size == 2
# decode receives latents (pixel area = latent area * 8^2); encode receives a pixel image.
if operation == "decode":
image_tensor = torch.zeros(1, 16, 1, 64, 64)
h = w = 64 * 8
else:
image_tensor = torch.zeros(1, 3, 1, 512, 512)
h = w = 512
hip_value = "7.1.0" if is_rocm else None
with patch("torch.version.hip", hip_value):
result = estimate_vae_working_memory_qwen_image(
operation=operation, image_tensor=image_tensor, vae=mock_vae
)
assert result == h * w * 2 * expected_constant
class TestQwenImageWorkingMemory:
"""Test that Qwen Image VAE invocations request working memory before decode/encode."""
def _mock_vae_info(self):
"""Build a mocked AutoencoderKLQwenImage and its LoadedModel wrapper."""
mock_vae = MagicMock(spec=AutoencoderKLQwenImage)
# Create mock parameter for dtype detection
mock_param = torch.zeros(1)
mock_vae.parameters.return_value = iter([mock_param])
# Create mock vae_info with a model_on_device context manager yielding (None, vae)
mock_vae_info = MagicMock()
mock_vae_info.model = mock_vae
mock_cm = MagicMock()
mock_cm.__enter__ = MagicMock(return_value=(None, mock_vae))
mock_cm.__exit__ = MagicMock(return_value=None)
mock_vae_info.model_on_device = MagicMock(return_value=mock_cm)
return mock_vae, mock_vae_info
def test_qwen_latents_to_image_requests_working_memory(self):
"""QwenImageLatentsToImageInvocation estimates decode memory and passes it to the cache."""
mock_vae, mock_vae_info = self._mock_vae_info()
# Mock the context
mock_context = MagicMock()
mock_context.models.load.return_value = mock_vae_info
# Mock latents (5D: B, C, num_frames, H, W)
mock_latents = torch.zeros(1, 16, 1, 64, 64)
mock_context.tensors.load.return_value = mock_latents
estimation_path = "invokeai.app.invocations.qwen_image_latents_to_image.estimate_vae_working_memory_qwen_image"
seamless_path = "invokeai.app.invocations.qwen_image_latents_to_image.SeamlessExt.static_patch_model"
with (
patch(estimation_path) as mock_estimate,
patch(seamless_path, return_value=nullcontext()),
):
expected_memory = 1024 * 1024 * 10000 # 10GB
mock_estimate.return_value = expected_memory
invocation = QwenImageLatentsToImageInvocation.model_construct(
latents=MagicMock(latents_name="test_latents"),
vae=MagicMock(vae=MagicMock(), seamless_axes=["x", "y"]),
)
try:
invocation.invoke(mock_context)
except Exception:
# Downstream decode math fails under mocking; we only care that the cache was
# asked to reserve the estimated working memory before entering the device context.
pass
mock_estimate.assert_called_once()
assert mock_estimate.call_args.kwargs["operation"] == "decode"
mock_vae_info.model_on_device.assert_called_once_with(working_mem_bytes=expected_memory)
def test_qwen_image_to_latents_requests_working_memory(self):
"""QwenImageImageToLatentsInvocation estimates encode memory and passes it to the cache."""
mock_vae, mock_vae_info = self._mock_vae_info()
mock_image_tensor = torch.zeros(1, 3, 512, 512)
estimation_path = "invokeai.app.invocations.qwen_image_image_to_latents.estimate_vae_working_memory_qwen_image"
with patch(estimation_path) as mock_estimate:
expected_memory = 1024 * 1024 * 5000 # 5GB
mock_estimate.return_value = expected_memory
try:
QwenImageImageToLatentsInvocation.vae_encode(mock_vae_info, mock_image_tensor)
except Exception:
# Downstream encode math fails under mocking; we only care that the cache was
# asked to reserve the estimated working memory before entering the device context.
pass
mock_estimate.assert_called_once()
assert mock_estimate.call_args.kwargs["operation"] == "encode"
mock_vae_info.model_on_device.assert_called_once_with(working_mem_bytes=expected_memory)
@@ -0,0 +1,197 @@
"""Tests for SaveImageToFileInvocation."""
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from PIL import Image
from pydantic import ValidationError
from invokeai.app.invocations.image import SaveImageToFileInvocation
def _make_context(tmp_path: Path, pil_image: Image.Image, gallery_uuid: str = "abc123") -> MagicMock:
context = MagicMock()
context.config.get.return_value.outputs_path = tmp_path
context.images.get_pil.return_value = pil_image
image_dto = MagicMock()
image_dto.image_name = f"{gallery_uuid}.png"
image_dto.width = pil_image.width
image_dto.height = pil_image.height
context.images.save.return_value = image_dto
return context
def _build_node(**overrides) -> SaveImageToFileInvocation:
defaults = {
"id": "test",
"image": {"image_name": "input.png"},
}
defaults.update(overrides)
return SaveImageToFileInvocation(**defaults)
class TestSaveImageToFileInvocation:
def test_saves_to_gallery(self, tmp_path):
img = Image.new("RGB", (8, 8), (255, 0, 0))
ctx = _make_context(tmp_path, img)
node = _build_node()
node.invoke(ctx)
ctx.images.save.assert_called_once()
assert ctx.images.save.call_args.kwargs["image"] is img
def test_default_directory_is_outputs_root(self, tmp_path):
img = Image.new("RGB", (8, 8))
ctx = _make_context(tmp_path, img, gallery_uuid="uuid-1")
node = _build_node()
node.invoke(ctx)
assert (tmp_path / "uuid-1.png").exists()
def test_relative_subdirectory_created(self, tmp_path):
img = Image.new("RGB", (8, 8))
ctx = _make_context(tmp_path, img, gallery_uuid="uuid-2")
node = _build_node(output_directory="my-exports")
node.invoke(ctx)
assert (tmp_path / "my-exports" / "uuid-2.png").exists()
def test_nested_relative_path(self, tmp_path):
img = Image.new("RGB", (8, 8))
ctx = _make_context(tmp_path, img, gallery_uuid="uuid-3")
node = _build_node(output_directory="exports/2026/hero")
node.invoke(ctx)
assert (tmp_path / "exports" / "2026" / "hero" / "uuid-3.png").exists()
def test_prefix_and_suffix_applied(self, tmp_path):
img = Image.new("RGB", (8, 8))
ctx = _make_context(tmp_path, img, gallery_uuid="xyz")
node = _build_node(prefix="hero_", suffix="_final")
node.invoke(ctx)
assert (tmp_path / "hero_xyz_final.png").exists()
def test_prefix_only(self, tmp_path):
img = Image.new("RGB", (8, 8))
ctx = _make_context(tmp_path, img, gallery_uuid="u")
node = _build_node(prefix="p_")
node.invoke(ctx)
assert (tmp_path / "p_u.png").exists()
def test_suffix_only(self, tmp_path):
img = Image.new("RGB", (8, 8))
ctx = _make_context(tmp_path, img, gallery_uuid="u")
node = _build_node(suffix="_s")
node.invoke(ctx)
assert (tmp_path / "u_s.png").exists()
def test_filename_uses_gallery_uuid_not_input_uuid(self, tmp_path):
"""The exported filename must use the UUID from the new gallery entry,
not the UUID of the input image_name."""
img = Image.new("RGB", (8, 8))
ctx = _make_context(tmp_path, img, gallery_uuid="new-uuid")
node = _build_node(image={"image_name": "old-uuid.png"})
node.invoke(ctx)
assert (tmp_path / "new-uuid.png").exists()
assert not (tmp_path / "old-uuid.png").exists()
@pytest.mark.parametrize(
"bad_path",
[
"D:/Pictures/Invoke",
"C:/Windows",
"/etc/passwd",
"/tmp/foo",
],
)
def test_absolute_paths_rejected(self, tmp_path, bad_path):
img = Image.new("RGB", (8, 8))
ctx = _make_context(tmp_path, img)
node = _build_node(output_directory=bad_path)
with pytest.raises(ValueError, match="[Aa]bsolute"):
node.invoke(ctx)
@pytest.mark.parametrize(
"traversal_path",
[
"../outside",
"subdir/../../outside",
"..",
],
)
def test_path_traversal_rejected(self, tmp_path, traversal_path):
img = Image.new("RGB", (8, 8))
ctx = _make_context(tmp_path, img)
node = _build_node(output_directory=traversal_path)
with pytest.raises(ValueError):
node.invoke(ctx)
def test_png_format(self, tmp_path):
img = Image.new("RGBA", (8, 8), (10, 20, 30, 128))
ctx = _make_context(tmp_path, img, gallery_uuid="u")
node = _build_node(file_format="png")
node.invoke(ctx)
path = tmp_path / "u.png"
assert path.exists()
with Image.open(path) as saved:
assert saved.format == "PNG"
assert saved.mode == "RGBA"
def test_jpg_format_converts_rgba_to_rgb(self, tmp_path):
img = Image.new("RGBA", (8, 8), (10, 20, 30, 128))
ctx = _make_context(tmp_path, img, gallery_uuid="u")
node = _build_node(file_format="jpg", quality=80)
node.invoke(ctx)
path = tmp_path / "u.jpg"
assert path.exists()
with Image.open(path) as saved:
assert saved.format == "JPEG"
assert saved.mode == "RGB"
def test_webp_format(self, tmp_path):
img = Image.new("RGB", (8, 8))
ctx = _make_context(tmp_path, img, gallery_uuid="u")
node = _build_node(file_format="webp", quality=75)
node.invoke(ctx)
path = tmp_path / "u.webp"
assert path.exists()
with Image.open(path) as saved:
assert saved.format == "WEBP"
def test_quality_bounds_enforced_by_pydantic(self):
with pytest.raises(ValidationError):
_build_node(quality=0)
with pytest.raises(ValidationError):
_build_node(quality=101)
def test_output_is_pass_through_of_gallery_dto(self, tmp_path):
img = Image.new("RGB", (16, 24))
ctx = _make_context(tmp_path, img, gallery_uuid="uuid-out")
node = _build_node()
result = node.invoke(ctx)
assert result.image.image_name == "uuid-out.png"
assert result.width == 16
assert result.height == 24
@@ -0,0 +1,153 @@
from contextlib import contextmanager, nullcontext
from types import SimpleNamespace
from unittest.mock import MagicMock
import torch
from invokeai.app.invocations.sd3_text_encoder import Sd3TextEncoderInvocation
from invokeai.backend.model_manager.taxonomy import ModelFormat
class FakeSd3ClipTextEncoder(torch.nn.Module):
def __init__(self, effective_device: torch.device):
super().__init__()
self.register_parameter("cpu_param", torch.nn.Parameter(torch.ones(1)))
self.register_buffer("active_buffer", torch.ones(1, device=effective_device))
self.dtype = torch.float32
self.forward_input_device: torch.device | None = None
@property
def device(self) -> torch.device:
return torch.device("cpu")
def forward(self, input_ids: torch.Tensor, output_hidden_states: bool = False):
assert output_hidden_states
self.forward_input_device = input_ids.device
hidden = input_ids.unsqueeze(-1).float()
return SimpleNamespace(hidden_states=[hidden, hidden + 1], __getitem__=lambda self, idx: hidden)
class FakeClipOutput(SimpleNamespace):
def __getitem__(self, idx):
del idx
return self.hidden_states[-1]
class FakeClipTokenizer:
def __call__(self, prompt, padding, max_length=None, truncation=None, return_tensors=None):
del prompt, padding, max_length, truncation, return_tensors
return SimpleNamespace(input_ids=torch.tensor([[1, 2, 3]], dtype=torch.long))
def batch_decode(self, input_ids):
del input_ids
return ["decoded"]
class FakeSd3T5Encoder(torch.nn.Module):
def __init__(self, effective_device: torch.device):
super().__init__()
self.register_parameter("cpu_param", torch.nn.Parameter(torch.ones(1)))
self.register_buffer("active_buffer", torch.ones(1, device=effective_device))
self.forward_input_device: torch.device | None = None
@property
def device(self) -> torch.device:
return torch.device("cpu")
def forward(self, input_ids: torch.Tensor):
self.forward_input_device = input_ids.device
hidden = input_ids.unsqueeze(-1).float()
return (hidden,)
class FakeT5Tokenizer:
def __call__(self, prompt, padding, max_length=None, truncation=None, add_special_tokens=None, return_tensors=None):
del prompt, padding, max_length, truncation, add_special_tokens, return_tensors
return SimpleNamespace(input_ids=torch.tensor([[1, 2, 3]], dtype=torch.long))
def batch_decode(self, input_ids):
del input_ids
return ["decoded"]
class FakeLoadedModel:
def __init__(self, model, config=None):
self._model = model
self.config = config
@contextmanager
def model_on_device(self):
yield (None, self._model)
def __enter__(self):
return self._model
def __exit__(self, exc_type, exc, tb):
return False
def test_sd3_clip_encode_uses_effective_device(monkeypatch):
module_path = "invokeai.app.invocations.sd3_text_encoder"
effective_device = torch.device("meta")
text_encoder = FakeSd3ClipTextEncoder(effective_device)
tokenizer = FakeClipTokenizer()
def forward(input_ids: torch.Tensor, output_hidden_states: bool = False):
assert output_hidden_states
text_encoder.forward_input_device = input_ids.device
hidden = input_ids.unsqueeze(-1).float()
return FakeClipOutput(hidden_states=[hidden, hidden + 1])
text_encoder.forward = forward # type: ignore[method-assign]
mock_context = MagicMock()
mock_context.models.load.side_effect = [
FakeLoadedModel(text_encoder, config=SimpleNamespace(format=ModelFormat.Diffusers)),
FakeLoadedModel(tokenizer),
]
mock_context.util.signal_progress = MagicMock()
monkeypatch.setattr(f"{module_path}.CLIPTextModel", FakeSd3ClipTextEncoder)
monkeypatch.setattr(f"{module_path}.CLIPTextModelWithProjection", FakeSd3ClipTextEncoder)
monkeypatch.setattr(f"{module_path}.CLIPTokenizer", FakeClipTokenizer)
monkeypatch.setattr(f"{module_path}.LayerPatcher.apply_smart_model_patches", lambda **kwargs: nullcontext())
invocation = Sd3TextEncoderInvocation.model_construct(
clip_l=SimpleNamespace(text_encoder=SimpleNamespace(), tokenizer=SimpleNamespace(), loras=[]),
clip_g=SimpleNamespace(text_encoder=SimpleNamespace(), tokenizer=SimpleNamespace(), loras=[]),
t5_encoder=None,
prompt="test prompt",
)
invocation._clip_encode(
context=mock_context,
clip_model=SimpleNamespace(text_encoder=SimpleNamespace(), tokenizer=SimpleNamespace(), loras=[]),
)
assert text_encoder.forward_input_device == effective_device
def test_sd3_t5_encode_uses_effective_device(monkeypatch):
module_path = "invokeai.app.invocations.sd3_text_encoder"
effective_device = torch.device("meta")
text_encoder = FakeSd3T5Encoder(effective_device)
tokenizer = FakeT5Tokenizer()
mock_context = MagicMock()
mock_context.models.load.side_effect = [FakeLoadedModel(text_encoder), FakeLoadedModel(tokenizer)]
mock_context.util.signal_progress = MagicMock()
mock_context.logger.warning = MagicMock()
monkeypatch.setattr(f"{module_path}.T5EncoderModel", FakeSd3T5Encoder)
monkeypatch.setattr(f"{module_path}.T5Tokenizer", FakeT5Tokenizer)
invocation = Sd3TextEncoderInvocation.model_construct(
clip_l=SimpleNamespace(text_encoder=SimpleNamespace(), tokenizer=SimpleNamespace(), loras=[]),
clip_g=SimpleNamespace(text_encoder=SimpleNamespace(), tokenizer=SimpleNamespace(), loras=[]),
t5_encoder=SimpleNamespace(text_encoder=SimpleNamespace(), tokenizer=SimpleNamespace()),
prompt="test prompt",
)
invocation._t5_encode(mock_context, max_seq_len=16)
assert text_encoder.forward_input_device == effective_device
@@ -0,0 +1,139 @@
"""Test that Z-Image VAE invocations properly estimate and request working memory."""
from unittest.mock import MagicMock, patch
import pytest
import torch
from diffusers.models.autoencoders.autoencoder_kl import AutoencoderKL
from invokeai.app.invocations.z_image_image_to_latents import ZImageImageToLatentsInvocation
from invokeai.backend.flux.modules.autoencoder import AutoEncoder as FluxAutoEncoder
class TestZImageWorkingMemory:
"""Test that Z-Image VAE invocations request working memory."""
@pytest.mark.parametrize("vae_type", [AutoencoderKL, FluxAutoEncoder])
def test_z_image_latents_to_image_requests_working_memory(self, vae_type):
"""Test that ZImageLatentsToImageInvocation estimates and requests working memory."""
# Create mock VAE
mock_vae = MagicMock(spec=vae_type)
# Only set config for AutoencoderKL (FluxAutoEncoder doesn't use config)
if vae_type == AutoencoderKL:
mock_vae.config.scaling_factor = 1.0
mock_vae.config.shift_factor = None
# Create mock parameter for dtype detection
mock_param = torch.zeros(1)
mock_vae.parameters.return_value = iter([mock_param])
# Create mock vae_info
mock_vae_info = MagicMock()
mock_vae_info.model = mock_vae
# Create mock context manager return value
mock_cm = MagicMock()
mock_cm.__enter__ = MagicMock(return_value=(None, mock_vae))
mock_cm.__exit__ = MagicMock(return_value=None)
mock_vae_info.model_on_device = MagicMock(return_value=mock_cm)
# Mock the context
mock_context = MagicMock()
mock_context.models.load.return_value = mock_vae_info
# Mock latents
mock_latents = torch.zeros(1, 16, 64, 64)
mock_context.tensors.load.return_value = mock_latents
estimation_path = "invokeai.app.invocations.z_image_latents_to_image.estimate_vae_working_memory_flux"
with patch(estimation_path) as mock_estimate:
expected_memory = 1024 * 1024 * 500 # 500MB
mock_estimate.return_value = expected_memory
# Mock VAE decode to avoid actual computation
if vae_type == FluxAutoEncoder:
mock_vae.decode.return_value = torch.zeros(1, 3, 512, 512)
else:
mock_vae.decode.return_value = (torch.zeros(1, 3, 512, 512),)
# Mock image save
mock_image_dto = MagicMock()
mock_context.images.save.return_value = mock_image_dto
# Import and create invocation using model_construct to bypass validation
from invokeai.app.invocations.z_image_latents_to_image import ZImageLatentsToImageInvocation
invocation = ZImageLatentsToImageInvocation.model_construct(
latents=MagicMock(latents_name="test_latents"),
vae=MagicMock(vae=MagicMock(), seamless_axes=["x", "y"]),
)
try:
invocation.invoke(mock_context)
except Exception:
# We expect some errors due to mocking, but we just want to verify the working memory was requested
pass
# Verify that working memory estimation was called
mock_estimate.assert_called_once()
# Verify that model_on_device was called with the estimated working memory
mock_vae_info.model_on_device.assert_called_once_with(working_mem_bytes=expected_memory)
@pytest.mark.parametrize("vae_type", [AutoencoderKL, FluxAutoEncoder])
def test_z_image_image_to_latents_requests_working_memory(self, vae_type):
"""Test that ZImageImageToLatentsInvocation estimates and requests working memory."""
# Create mock VAE
mock_vae = MagicMock(spec=vae_type)
# Only set config for AutoencoderKL (FluxAutoEncoder doesn't use config)
if vae_type == AutoencoderKL:
mock_vae.config.scaling_factor = 1.0
mock_vae.config.shift_factor = None
# Create mock parameter for dtype detection
mock_param = torch.zeros(1)
mock_vae.parameters.return_value = iter([mock_param])
# Create mock vae_info
mock_vae_info = MagicMock()
mock_vae_info.model = mock_vae
# Create mock context manager return value
mock_cm = MagicMock()
mock_cm.__enter__ = MagicMock(return_value=(None, mock_vae))
mock_cm.__exit__ = MagicMock(return_value=None)
mock_vae_info.model_on_device = MagicMock(return_value=mock_cm)
# Mock image tensor
mock_image_tensor = torch.zeros(1, 3, 512, 512)
# Mock the estimation function
estimation_path = "invokeai.app.invocations.z_image_image_to_latents.estimate_vae_working_memory_flux"
with patch(estimation_path) as mock_estimate:
expected_memory = 1024 * 1024 * 250 # 250MB
mock_estimate.return_value = expected_memory
# Mock VAE encode to avoid actual computation
if vae_type == FluxAutoEncoder:
mock_vae.encode.return_value = torch.zeros(1, 16, 64, 64)
else:
mock_latent_dist = MagicMock()
mock_latent_dist.sample.return_value = torch.zeros(1, 16, 64, 64)
mock_encode_result = MagicMock()
mock_encode_result.latent_dist = mock_latent_dist
mock_vae.encode.return_value = mock_encode_result
# Call the static method directly
try:
ZImageImageToLatentsInvocation.vae_encode(mock_vae_info, mock_image_tensor)
except Exception:
# We expect some errors due to mocking, but we just want to verify the working memory was requested
pass
# Verify that working memory estimation was called
mock_estimate.assert_called_once()
# Verify that model_on_device was called with the estimated working memory
mock_vae_info.model_on_device.assert_called_once_with(working_mem_bytes=expected_memory)
+145
View File
@@ -0,0 +1,145 @@
"""Shared fixtures and helpers for router-level multiuser/auth tests.
Note: This conftest intentionally does NOT redefine `mock_services` / `mock_invoker`
to avoid shadowing the project-level fixtures in `tests/conftest.py`. Instead, the
`enable_multiuser` fixture below injects MagicMock services for the routers that
have no real backing service in the default mock_services (download_queue,
style_preset_image_files, model_relationships, model_manager).
WARNING for future authors: `enable_multiuser` *unconditionally* replaces several
services on `mock_invoker.services` with MagicMocks. If you add a router test that
expects the real service (e.g. a SQLite-backed implementation), you must restore
that service in your own fixture before the request — otherwise your test will
pass against a MagicMock that accepts every call. `style_preset_records` is wired
up to a real SQLite storage here because cross-user filtering needs real SQL.
WARNING for new routers: every router that calls `ApiDependencies.invoker.services`
must be added to `_PATCHED_API_DEPENDENCIES_MODULES` below, or its routes will hit
the un-patched singleton and fail with `AttributeError: type object
'ApiDependencies' has no attribute 'invoker'`.
Existing test files that define their own `enable_multiuser` / `admin_token` / etc.
fixtures locally are NOT affected — pytest's local-shadows-conftest rule applies.
"""
from typing import Any
from unittest.mock import MagicMock
import pytest
from fastapi.testclient import TestClient
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.api_app import app
from invokeai.app.services.invoker import Invoker
from invokeai.app.services.users.users_common import UserCreateRequest
class MockApiDependencies(ApiDependencies):
"""Minimal stand-in that lets tests inject their own Invoker."""
invoker: Invoker
def __init__(self, invoker: Invoker) -> None:
self.invoker = invoker
# Every module that reads `ApiDependencies.invoker.services` must be patched here
# so the in-test invoker is reachable. Add new routers / shared helpers to this
# list when they start touching ApiDependencies.
_PATCHED_API_DEPENDENCIES_MODULES = (
"invokeai.app.api.auth_dependencies",
"invokeai.app.api.routers.auth",
"invokeai.app.api.routers.download_queue",
"invokeai.app.api.routers.style_presets",
"invokeai.app.api.routers.model_relationships",
"invokeai.app.api.routers.utilities",
"invokeai.app.api.routers.virtual_boards",
"invokeai.app.api.routers.images",
"invokeai.app.api.routers.workflows",
"invokeai.app.api.routers._access",
)
@pytest.fixture
def setup_jwt_secret():
from invokeai.app.services.auth.token_service import set_jwt_secret
set_jwt_secret("test-secret-key-for-unit-tests-only-do-not-use-in-production")
@pytest.fixture
def client():
return TestClient(app)
def _create_user(mock_invoker: Invoker, email: str, display_name: str, is_admin: bool = False) -> str:
user = mock_invoker.services.users.create(
UserCreateRequest(email=email, display_name=display_name, password="TestPass123", is_admin=is_admin)
)
return user.user_id
def _login(client: TestClient, email: str) -> str:
r = client.post("/api/v1/auth/login", json={"email": email, "password": "TestPass123", "remember_me": False})
assert r.status_code == 200, f"Login failed for {email}: {r.text}"
return r.json()["token"]
def _auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
@pytest.fixture
def enable_multiuser(monkeypatch: Any, mock_invoker: Invoker):
"""Enable multiuser mode and patch ApiDependencies across the routers covered by router-level tests.
Replaces None-valued services with MagicMocks so that routes can run end-to-end.
"""
from invokeai.app.services.style_preset_records.style_preset_records_sqlite import (
SqliteStylePresetRecordsStorage,
)
mock_invoker.services.configuration.multiuser = True
# Replace services that are None in the default mock_services with MagicMocks.
mock_invoker.services.download_queue = MagicMock()
mock_invoker.services.style_preset_image_files = MagicMock()
mock_invoker.services.model_relationships = MagicMock()
mock_invoker.services.model_manager = MagicMock()
mock_invoker.services.workflow_thumbnails = MagicMock()
# Style preset records uses a real SQLite-backed storage on the same in-memory
# database that image_records was wired up against. This lets cross-user tests
# exercise the actual filter SQL instead of asserting on MagicMock calls.
mock_invoker.services.style_preset_records = SqliteStylePresetRecordsStorage(
db=mock_invoker.services.image_records._db
)
# Required by board_image_records-touching helpers in some tests.
if mock_invoker.services.board_images is None:
mock_board_images = MagicMock()
mock_board_images.get_all_board_image_names_for_board.return_value = []
mock_invoker.services.board_images = mock_board_images
mock_deps = MockApiDependencies(mock_invoker)
for module_path in _PATCHED_API_DEPENDENCIES_MODULES:
monkeypatch.setattr(f"{module_path}.ApiDependencies", mock_deps)
yield
@pytest.fixture
def admin_token(setup_jwt_secret: None, enable_multiuser: Any, mock_invoker: Invoker, client: TestClient) -> str:
_create_user(mock_invoker, "admin@test.com", "Admin", is_admin=True)
return _login(client, "admin@test.com")
@pytest.fixture
def user1_token(enable_multiuser: Any, mock_invoker: Invoker, client: TestClient, admin_token: str) -> str:
_create_user(mock_invoker, "user1@test.com", "User One")
return _login(client, "user1@test.com")
@pytest.fixture
def user2_token(enable_multiuser: Any, mock_invoker: Invoker, client: TestClient, admin_token: str) -> str:
_create_user(mock_invoker, "user2@test.com", "User Two")
return _login(client, "user2@test.com")
+336
View File
@@ -0,0 +1,336 @@
import os
from pathlib import Path
from typing import Any
from unittest.mock import Mock
import pytest
from fastapi.testclient import TestClient
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.api.routers import app_info
from invokeai.app.api_app import app
from invokeai.app.services.auth.token_service import TokenData
from invokeai.app.services.config.config_default import get_config, load_and_migrate_config, load_external_api_keys
from invokeai.app.services.external_generation.external_generation_common import ExternalProviderStatus
from invokeai.app.services.image_files.image_subfolder_strategy import DateStrategy, create_subfolder_strategy
from invokeai.app.services.invoker import Invoker
from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig, ExternalModelCapabilities
from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType
@pytest.fixture(autouse=True, scope="module")
def client(invokeai_root_dir: Path) -> TestClient:
os.environ["INVOKEAI_ROOT"] = invokeai_root_dir.as_posix()
return TestClient(app)
class MockApiDependencies(ApiDependencies):
invoker: Invoker
def __init__(self, invoker: Invoker) -> None:
self.invoker = invoker
def test_get_external_provider_statuses(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None:
statuses = {
"gemini": ExternalProviderStatus(provider_id="gemini", configured=True, message=None),
"openai": ExternalProviderStatus(provider_id="openai", configured=False, message="Missing key"),
}
monkeypatch.setattr("invokeai.app.api.routers.app_info.ApiDependencies", MockApiDependencies(mock_invoker))
monkeypatch.setattr(mock_invoker.services.external_generation, "get_provider_statuses", lambda: statuses)
response = client.get("/api/v1/app/external_providers/status")
assert response.status_code == 200
payload = sorted(response.json(), key=lambda item: item["provider_id"])
assert payload == [
{"provider_id": "gemini", "configured": True, "message": None},
{"provider_id": "openai", "configured": False, "message": "Missing key"},
]
def test_external_provider_config_update_and_reset(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None:
mock_store = Mock()
mock_store.search_by_attr.return_value = []
mock_install = Mock()
mock_model_manager = Mock()
mock_model_manager.store = mock_store
mock_model_manager.install = mock_install
mock_invoker.services.model_manager = mock_model_manager
monkeypatch.setattr("invokeai.app.api.routers.app_info.ApiDependencies", MockApiDependencies(mock_invoker))
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
for provider_id in ("gemini", "openai"):
response = client.delete(f"/api/v1/app/external_providers/config/{provider_id}")
assert response.status_code == 200
response = client.get("/api/v1/app/external_providers/config")
assert response.status_code == 200
payload = response.json()
openai_config = _get_provider_config(payload, "openai")
assert openai_config["api_key_configured"] is False
assert openai_config["base_url"] is None
response = client.post(
"/api/v1/app/external_providers/config/openai",
json={"api_key": "openai-key", "base_url": "https://api.openai.test"},
)
assert response.status_code == 200
payload = response.json()
assert payload["api_key_configured"] is True
assert payload["base_url"] == "https://api.openai.test"
response = client.get("/api/v1/app/external_providers/config")
assert response.status_code == 200
payload = response.json()
openai_config = _get_provider_config(payload, "openai")
assert openai_config["api_key_configured"] is True
assert openai_config["base_url"] == "https://api.openai.test"
config_path = get_config().config_file_path
api_keys_path = get_config().api_keys_file_path
file_config = load_and_migrate_config(config_path)
assert file_config.external_openai_api_key is None
assert file_config.external_openai_base_url is None
assert "external_openai_api_key" not in config_path.read_text()
assert "external_openai_base_url" not in config_path.read_text()
api_keys = load_external_api_keys(api_keys_path)
assert api_keys["external_openai_api_key"] == "openai-key"
assert api_keys["external_openai_base_url"] == "https://api.openai.test"
response = client.delete("/api/v1/app/external_providers/config/openai")
assert response.status_code == 200
payload = response.json()
assert payload["api_key_configured"] is False
assert payload["base_url"] is None
file_config = load_and_migrate_config(config_path)
api_keys = load_external_api_keys(api_keys_path)
assert file_config.external_openai_api_key is None
assert file_config.external_openai_base_url is None
assert "external_openai_api_key" not in config_path.read_text()
assert "external_openai_api_key" not in api_keys
assert "external_openai_base_url" not in api_keys
def test_reset_external_provider_config_removes_provider_models(
monkeypatch: Any, mock_invoker: Invoker, client: TestClient
) -> None:
openai_model = ExternalApiModelConfig(
key="openai_model",
name="OpenAI Model",
provider_id="openai",
provider_model_id="gpt-image-1",
capabilities=ExternalModelCapabilities(modes=["txt2img"]),
)
gemini_model = ExternalApiModelConfig(
key="gemini_model",
name="Gemini Model",
provider_id="gemini",
provider_model_id="gemini-2.5-flash-image",
capabilities=ExternalModelCapabilities(modes=["txt2img"]),
)
mock_store = Mock()
mock_store.search_by_attr.return_value = [openai_model, gemini_model]
mock_install = Mock()
mock_model_manager = Mock()
mock_model_manager.store = mock_store
mock_model_manager.install = mock_install
mock_invoker.services.model_manager = mock_model_manager
monkeypatch.setattr("invokeai.app.api.routers.app_info.ApiDependencies", MockApiDependencies(mock_invoker))
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
response = client.delete("/api/v1/app/external_providers/config/openai")
assert response.status_code == 200
mock_store.search_by_attr.assert_called_once_with(
base_model=BaseModelType.External,
model_type=ModelType.ExternalImageGenerator,
)
mock_install.delete.assert_called_once_with("openai_model")
def test_set_external_provider_config_clears_provider_models_when_api_key_removed(
monkeypatch: Any, mock_invoker: Invoker, client: TestClient
) -> None:
openai_model = ExternalApiModelConfig(
key="openai_model",
name="OpenAI Model",
provider_id="openai",
provider_model_id="gpt-image-1",
capabilities=ExternalModelCapabilities(modes=["txt2img"]),
)
mock_store = Mock()
mock_store.search_by_attr.return_value = [openai_model]
mock_install = Mock()
mock_model_manager = Mock()
mock_model_manager.store = mock_store
mock_model_manager.install = mock_install
mock_invoker.services.model_manager = mock_model_manager
monkeypatch.setattr("invokeai.app.api.routers.app_info.ApiDependencies", MockApiDependencies(mock_invoker))
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
response = client.post("/api/v1/app/external_providers/config/openai", json={"api_key": " "})
assert response.status_code == 200
mock_store.search_by_attr.assert_called_once_with(
base_model=BaseModelType.External,
model_type=ModelType.ExternalImageGenerator,
)
mock_install.delete.assert_called_once_with("openai_model")
def test_update_runtime_config_persists_image_subfolder_strategy(
monkeypatch: Any, mock_invoker: Invoker, client: TestClient
) -> None:
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
response = client.patch("/api/v1/app/runtime_config", json={"image_subfolder_strategy": "date"})
assert response.status_code == 200
assert response.json()["config"]["image_subfolder_strategy"] == "date"
config_path = get_config().config_file_path
file_config = load_and_migrate_config(config_path)
assert file_config.image_subfolder_strategy == "date"
assert "image_subfolder_strategy: date" in config_path.read_text()
assert get_config().image_subfolder_strategy == "date"
assert isinstance(create_subfolder_strategy(get_config().image_subfolder_strategy), DateStrategy)
def test_update_runtime_config_rejects_null_image_subfolder_strategy(
monkeypatch: Any, mock_invoker: Invoker, client: TestClient
) -> None:
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
response = client.patch("/api/v1/app/runtime_config", json={"image_subfolder_strategy": None})
assert response.status_code == 422
def test_update_runtime_config_image_subfolder_strategy_schema() -> None:
app.openapi_schema = None
property_schema = app.openapi()["components"]["schemas"]["UpdateAppGenerationSettingsRequest"]["properties"][
"image_subfolder_strategy"
]
assert property_schema == {
"description": "Strategy for organizing images into subfolders.",
"enum": ["flat", "date", "type", "hash"],
"title": "Image Subfolder Strategy",
"type": "string",
}
def test_update_runtime_config_reads_and_writes_yaml_under_config_lock(
monkeypatch: Any, mock_invoker: Invoker, client: TestClient
) -> None:
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
class TrackingLock:
is_locked = False
load_seen = False
write_seen = False
def __enter__(self) -> None:
self.is_locked = True
def __exit__(self, *_: Any) -> None:
self.is_locked = False
tracking_lock = TrackingLock()
original_load_and_migrate_config = app_info.load_and_migrate_config
original_write_file = app_info.InvokeAIAppConfig.write_file
def load_and_migrate_config_with_lock_assertion(config_path: Path) -> Any:
assert tracking_lock.is_locked
tracking_lock.load_seen = True
return original_load_and_migrate_config(config_path)
def write_file_with_lock_assertion(
config: app_info.InvokeAIAppConfig, dest_path: Path, as_example: bool = False
) -> None:
assert tracking_lock.is_locked
tracking_lock.write_seen = True
return original_write_file(config, dest_path, as_example)
monkeypatch.setattr(app_info, "_EXTERNAL_PROVIDER_CONFIG_LOCK", tracking_lock)
monkeypatch.setattr(app_info, "load_and_migrate_config", load_and_migrate_config_with_lock_assertion)
monkeypatch.setattr(app_info.InvokeAIAppConfig, "write_file", write_file_with_lock_assertion)
response = client.patch("/api/v1/app/runtime_config", json={"max_queue_history": 10})
assert response.status_code == 200
assert tracking_lock.load_seen
assert tracking_lock.write_seen
def test_update_runtime_config_rejects_non_admin_users(
monkeypatch: Any, mock_invoker: Invoker, client: TestClient
) -> None:
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
monkeypatch.setattr(mock_invoker.services.configuration, "multiuser", True)
monkeypatch.setattr(
"invokeai.app.api.auth_dependencies.verify_token",
lambda _: TokenData(user_id="user-1", email="user@example.com", is_admin=False),
)
monkeypatch.setattr(mock_invoker.services.users, "get", Mock(return_value=Mock(is_active=True)))
response = client.patch(
"/api/v1/app/runtime_config",
json={"image_subfolder_strategy": "date"},
headers={"Authorization": "Bearer non-admin-token"},
)
assert response.status_code == 403
assert response.json()["detail"] == "Admin privileges required"
@pytest.mark.parametrize("provider_id", ["alibabacloud", "gemini", "openai", "seedream"])
def test_set_external_provider_config_rejects_non_admin_users(
monkeypatch: Any, mock_invoker: Invoker, client: TestClient, provider_id: str
) -> None:
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
monkeypatch.setattr(mock_invoker.services.configuration, "multiuser", True)
monkeypatch.setattr(
"invokeai.app.api.auth_dependencies.verify_token",
lambda _: TokenData(user_id="user-1", email="user@example.com", is_admin=False),
)
monkeypatch.setattr(mock_invoker.services.users, "get", Mock(return_value=Mock(is_active=True)))
response = client.post(
f"/api/v1/app/external_providers/config/{provider_id}",
json={"api_key": "non-admin-attempt"},
headers={"Authorization": "Bearer non-admin-token"},
)
assert response.status_code == 403
assert response.json()["detail"] == "Admin privileges required"
@pytest.mark.parametrize("provider_id", ["alibabacloud", "gemini", "openai", "seedream"])
def test_reset_external_provider_config_rejects_non_admin_users(
monkeypatch: Any, mock_invoker: Invoker, client: TestClient, provider_id: str
) -> None:
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
monkeypatch.setattr(mock_invoker.services.configuration, "multiuser", True)
monkeypatch.setattr(
"invokeai.app.api.auth_dependencies.verify_token",
lambda _: TokenData(user_id="user-1", email="user@example.com", is_admin=False),
)
monkeypatch.setattr(mock_invoker.services.users, "get", Mock(return_value=Mock(is_active=True)))
response = client.delete(
f"/api/v1/app/external_providers/config/{provider_id}",
headers={"Authorization": "Bearer non-admin-token"},
)
assert response.status_code == 403
assert response.json()["detail"] == "Admin privileges required"
def _get_provider_config(payload: list[dict[str, Any]], provider_id: str) -> dict[str, Any]:
return next(item for item in payload if item["provider_id"] == provider_id)
+356
View File
@@ -0,0 +1,356 @@
"""Integration tests for authentication router endpoints."""
import os
from pathlib import Path
from typing import Any
import pytest
from fastapi.testclient import TestClient
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.api_app import app
from invokeai.app.services.auth.token_service import set_jwt_secret
from invokeai.app.services.invoker import Invoker
from invokeai.app.services.users.users_common import UserCreateRequest
@pytest.fixture(autouse=True, scope="module")
def setup_jwt_secret():
"""Set up JWT secret for all tests in this module."""
# Use a test secret key
set_jwt_secret("test-secret-key-for-unit-tests-only-do-not-use-in-production")
@pytest.fixture(autouse=True, scope="module")
def client(invokeai_root_dir: Path) -> TestClient:
"""Create a test client for the FastAPI app."""
os.environ["INVOKEAI_ROOT"] = invokeai_root_dir.as_posix()
return TestClient(app)
@pytest.fixture(autouse=True)
def enable_multiuser_for_auth_tests(mock_invoker: Invoker) -> None:
"""Enable multiuser mode for auth tests.
Auth tests need multiuser mode enabled since the login/setup endpoints
return 403 when multiuser is disabled.
"""
mock_invoker.services.configuration.multiuser = True
class MockApiDependencies(ApiDependencies):
"""Mock API dependencies for testing."""
invoker: Invoker
def __init__(self, invoker) -> None:
self.invoker = invoker
def setup_test_user(mock_invoker: Invoker, email: str = "test@example.com", password: str = "TestPass123") -> str:
"""Helper to create a test user and return user_id."""
user_service = mock_invoker.services.users
user_data = UserCreateRequest(
email=email,
display_name="Test User",
password=password,
is_admin=False,
)
user = user_service.create(user_data)
return user.user_id
def setup_test_admin(mock_invoker: Invoker, email: str = "admin@example.com", password: str = "AdminPass123") -> str:
"""Helper to create a test admin user and return user_id."""
user_service = mock_invoker.services.users
user_data = UserCreateRequest(
email=email,
display_name="Admin User",
password=password,
is_admin=True,
)
user = user_service.create(user_data)
return user.user_id
def test_login_success(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None:
"""Test successful login with valid credentials."""
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
# Create a test user
setup_test_user(mock_invoker, "test@example.com", "TestPass123")
# Attempt login
response = client.post(
"/api/v1/auth/login",
json={
"email": "test@example.com",
"password": "TestPass123",
"remember_me": False,
},
)
assert response.status_code == 200
json_response = response.json()
assert "token" in json_response
assert "user" in json_response
assert "expires_in" in json_response
assert json_response["user"]["email"] == "test@example.com"
assert json_response["user"]["is_admin"] is False
def test_login_with_remember_me(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None:
"""Test login with remember_me flag sets longer expiration."""
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
setup_test_user(mock_invoker, "test2@example.com", "TestPass123")
# Login with remember_me=True
response = client.post(
"/api/v1/auth/login",
json={
"email": "test2@example.com",
"password": "TestPass123",
"remember_me": True,
},
)
assert response.status_code == 200
json_response = response.json()
# Remember me should give 7 days = 604800 seconds
assert json_response["expires_in"] == 604800
def test_login_invalid_password(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None:
"""Test login fails with invalid password."""
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
setup_test_user(mock_invoker, "test3@example.com", "TestPass123")
response = client.post(
"/api/v1/auth/login",
json={
"email": "test3@example.com",
"password": "WrongPassword",
"remember_me": False,
},
)
assert response.status_code == 401
assert "Incorrect email or password" in response.json()["detail"]
def test_login_nonexistent_user(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None:
"""Test login fails with nonexistent user."""
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
response = client.post(
"/api/v1/auth/login",
json={
"email": "nonexistent@example.com",
"password": "TestPass123",
"remember_me": False,
},
)
assert response.status_code == 401
assert "Incorrect email or password" in response.json()["detail"]
def test_login_inactive_user(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None:
"""Test login fails with inactive user."""
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
user_id = setup_test_user(mock_invoker, "inactive@example.com", "TestPass123")
# Deactivate the user
user_service = mock_invoker.services.users
from invokeai.app.services.users.users_common import UserUpdateRequest
user_service.update(user_id, UserUpdateRequest(is_active=False))
response = client.post(
"/api/v1/auth/login",
json={
"email": "inactive@example.com",
"password": "TestPass123",
"remember_me": False,
},
)
assert response.status_code == 403
assert "disabled" in response.json()["detail"]
def test_logout(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None:
"""Test logout endpoint."""
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
setup_test_user(mock_invoker, "test4@example.com", "TestPass123")
# Login first to get token
login_response = client.post(
"/api/v1/auth/login",
json={
"email": "test4@example.com",
"password": "TestPass123",
"remember_me": False,
},
)
token = login_response.json()["token"]
# Logout with token
response = client.post("/api/v1/auth/logout", headers={"Authorization": f"Bearer {token}"})
assert response.status_code == 200
assert response.json()["success"] is True
def test_logout_without_token(client: TestClient) -> None:
"""Test logout fails without authentication token."""
response = client.post("/api/v1/auth/logout")
assert response.status_code == 401
def test_get_current_user_info(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None:
"""Test getting current user info with valid token."""
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
setup_test_user(mock_invoker, "test5@example.com", "TestPass123")
# Login to get token
login_response = client.post(
"/api/v1/auth/login",
json={
"email": "test5@example.com",
"password": "TestPass123",
"remember_me": False,
},
)
token = login_response.json()["token"]
# Get user info
response = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"})
assert response.status_code == 200
json_response = response.json()
assert json_response["email"] == "test5@example.com"
assert json_response["display_name"] == "Test User"
assert json_response["is_admin"] is False
def test_get_current_user_info_without_token(client: TestClient) -> None:
"""Test getting user info fails without token."""
response = client.get("/api/v1/auth/me")
assert response.status_code == 401
def test_get_current_user_info_invalid_token(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None:
"""Test getting user info fails with invalid token."""
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
response = client.get("/api/v1/auth/me", headers={"Authorization": "Bearer invalid_token"})
assert response.status_code == 401
def test_setup_admin_first_time(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None:
"""Test setting up first admin user."""
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
response = client.post(
"/api/v1/auth/setup",
json={
"email": "admin@example.com",
"display_name": "Admin User",
"password": "AdminPass123",
},
)
assert response.status_code == 200
json_response = response.json()
assert json_response["success"] is True
assert json_response["user"]["email"] == "admin@example.com"
assert json_response["user"]["is_admin"] is True
def test_setup_admin_already_exists(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None:
"""Test setup fails when admin already exists."""
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
# Create first admin
setup_test_admin(mock_invoker, "admin1@example.com", "AdminPass123")
# Try to setup another admin
response = client.post(
"/api/v1/auth/setup",
json={
"email": "admin2@example.com",
"display_name": "Second Admin",
"password": "AdminPass123",
},
)
assert response.status_code == 400
assert "already configured" in response.json()["detail"]
def test_setup_admin_weak_password(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None:
"""Test setup fails with weak password when strict password checking is enabled."""
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
mock_invoker.services.configuration.strict_password_checking = True
response = client.post(
"/api/v1/auth/setup",
json={
"email": "admin3@example.com",
"display_name": "Admin User",
"password": "weak",
},
)
assert response.status_code == 400
assert "Password" in response.json()["detail"]
def test_setup_admin_weak_password_non_strict(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None:
"""Test setup succeeds with weak password when strict password checking is disabled (the default)."""
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
mock_invoker.services.configuration.strict_password_checking = False
response = client.post(
"/api/v1/auth/setup",
json={
"email": "admin3b@example.com",
"display_name": "Admin User",
"password": "weak",
},
)
assert response.status_code == 200
json_response = response.json()
assert json_response["success"] is True
def test_admin_user_token_has_admin_flag(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None:
"""Test that admin user login returns token with admin flag."""
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
setup_test_admin(mock_invoker, "admin4@example.com", "AdminPass123")
response = client.post(
"/api/v1/auth/login",
json={
"email": "admin4@example.com",
"password": "AdminPass123",
"remember_me": False,
},
)
assert response.status_code == 200
json_response = response.json()
assert json_response["user"]["is_admin"] is True
@@ -0,0 +1,154 @@
from unittest.mock import MagicMock
import pytest
from fastapi.testclient import TestClient
from invokeai.app.api.auth_dependencies import get_current_user_or_default
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.api_app import app
from invokeai.app.services.auth.token_service import TokenData
from invokeai.app.services.board_records.board_records_common import BoardVisibility
from invokeai.app.services.boards.boards_common import BoardDTO
from invokeai.app.services.invoker import Invoker
class MockApiDependencies(ApiDependencies):
invoker: Invoker
def __init__(self, invoker) -> None:
self.invoker = invoker
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
@pytest.mark.parametrize(
("method", "path", "json_body"),
[
("post", "/api/v1/board_images/", {"board_id": "board-id", "image_name": "image.png"}),
("delete", "/api/v1/board_images/", {"image_name": "image.png"}),
("post", "/api/v1/board_images/batch", {"board_id": "board-id", "image_names": ["image.png"]}),
("post", "/api/v1/board_images/batch/delete", {"image_names": ["image.png"]}),
],
)
def test_board_image_mutations_are_blocked_during_image_move_maintenance(
monkeypatch: pytest.MonkeyPatch,
mock_invoker: Invoker,
client: TestClient,
method: str,
path: str,
json_body: dict,
) -> None:
mock_deps = MockApiDependencies(mock_invoker)
mock_invoker.services.image_moves = MagicMock()
mock_invoker.services.image_moves.is_maintenance_active.return_value = True
monkeypatch.setattr(mock_invoker.services.image_records, "get_user_id", MagicMock(return_value="system"))
monkeypatch.setattr(mock_invoker.services.images, "get_dto", MagicMock(return_value=MagicMock(board_id=None)))
monkeypatch.setattr(
mock_invoker.services.boards,
"get_dto",
MagicMock(
return_value=BoardDTO(
board_id="board-id",
board_name="Board",
user_id="system",
created_at="2024-01-01 00:00:00.000",
updated_at="2024-01-01 00:00:00.000",
archived=False,
board_visibility=BoardVisibility.Private,
cover_image_name=None,
image_count=0,
asset_count=0,
)
),
)
monkeypatch.setattr("invokeai.app.api.routers.board_images.ApiDependencies", mock_deps)
monkeypatch.setattr("invokeai.app.api.routers._access.ApiDependencies", mock_deps)
monkeypatch.setattr("invokeai.app.api.routers.image_move_maintenance.ApiDependencies", mock_deps)
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps)
response = client.request(method, path, json=json_body)
assert response.status_code == 409
assert response.json()["detail"] == "Image storage maintenance is active"
def test_board_image_mutation_checks_access_before_image_move_maintenance(
monkeypatch: pytest.MonkeyPatch,
mock_invoker: Invoker,
client: TestClient,
) -> None:
mock_deps = MockApiDependencies(mock_invoker)
mock_invoker.services.image_moves = MagicMock()
mock_invoker.services.image_moves.is_maintenance_active.return_value = True
monkeypatch.setattr(mock_invoker.services.image_records, "get_user_id", MagicMock(return_value="other-user"))
monkeypatch.setattr(
mock_invoker.services.boards,
"get_dto",
MagicMock(
return_value=BoardDTO(
board_id="board-id",
board_name="Board",
user_id="system",
created_at="2024-01-01 00:00:00.000",
updated_at="2024-01-01 00:00:00.000",
archived=False,
board_visibility=BoardVisibility.Private,
cover_image_name=None,
image_count=0,
asset_count=0,
)
),
)
monkeypatch.setattr("invokeai.app.api.routers.board_images.ApiDependencies", mock_deps)
monkeypatch.setattr("invokeai.app.api.routers._access.ApiDependencies", mock_deps)
monkeypatch.setattr("invokeai.app.api.routers.image_move_maintenance.ApiDependencies", mock_deps)
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps)
async def current_user_override() -> TokenData:
return TokenData(user_id="request-user", email="request-user@example.com", is_admin=False)
app.dependency_overrides[get_current_user_or_default] = current_user_override
try:
response = client.post("/api/v1/board_images/", json={"board_id": "board-id", "image_name": "image.png"})
assert response.status_code == 403
mock_invoker.services.image_moves.is_maintenance_active.assert_not_called()
finally:
app.dependency_overrides.pop(get_current_user_or_default, None)
def test_delete_board_with_images_is_blocked_during_image_move_maintenance(
monkeypatch: pytest.MonkeyPatch,
mock_invoker: Invoker,
client: TestClient,
) -> None:
mock_deps = MockApiDependencies(mock_invoker)
mock_invoker.services.image_moves = MagicMock()
mock_invoker.services.image_moves.is_maintenance_active.return_value = True
mock_invoker.services.images.delete_images_on_board = MagicMock()
mock_invoker.services.boards.get_dto = MagicMock(
return_value=BoardDTO(
board_id="board-id",
board_name="Board",
user_id="system",
created_at="2024-01-01 00:00:00.000",
updated_at="2024-01-01 00:00:00.000",
archived=False,
board_visibility=BoardVisibility.Private,
cover_image_name=None,
image_count=0,
asset_count=0,
)
)
monkeypatch.setattr("invokeai.app.api.routers.boards.ApiDependencies", mock_deps)
monkeypatch.setattr("invokeai.app.api.routers.image_move_maintenance.ApiDependencies", mock_deps)
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps)
response = client.delete("/api/v1/boards/board-id?include_images=true")
assert response.status_code == 409
assert response.json()["detail"] == "Image storage maintenance is active"
mock_invoker.services.images.delete_images_on_board.assert_not_called()
+677
View File
@@ -0,0 +1,677 @@
"""Tests for multiuser boards functionality."""
from typing import Any
from unittest.mock import MagicMock
import pytest
from fastapi import status
from fastapi.testclient import TestClient
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.api_app import app
from invokeai.app.services.invoker import Invoker
from invokeai.app.services.users.users_common import UserCreateRequest
class MockApiDependencies(ApiDependencies):
"""Mock API dependencies for testing."""
invoker: Invoker
def __init__(self, invoker: Invoker) -> None:
self.invoker = invoker
@pytest.fixture
def setup_jwt_secret():
"""Initialize JWT secret for token generation."""
from invokeai.app.services.auth.token_service import set_jwt_secret
# Use a test secret key
set_jwt_secret("test-secret-key-for-unit-tests-only-do-not-use-in-production")
@pytest.fixture
def client():
"""Create a test client."""
return TestClient(app)
def setup_test_user(
mock_invoker: Invoker,
email: str,
display_name: str,
password: str = "TestPass123",
is_admin: bool = False,
) -> str:
"""Helper to create a test user and return user_id."""
user_service = mock_invoker.services.users
user_data = UserCreateRequest(
email=email,
display_name=display_name,
password=password,
is_admin=is_admin,
)
user = user_service.create(user_data)
return user.user_id
def get_user_token(client: TestClient, email: str, password: str = "TestPass123") -> str:
"""Helper to login and get a user token."""
response = client.post(
"/api/v1/auth/login",
json={"email": email, "password": password, "remember_me": False},
)
assert response.status_code == 200
return response.json()["token"]
@pytest.fixture
def enable_multiuser_for_tests(monkeypatch: Any, mock_invoker: Invoker):
"""Enable multiuser mode and patch ApiDependencies for all relevant routers."""
mock_invoker.services.configuration.multiuser = True
# Provide a mock board_images service so delete/image_names endpoints don't 500
mock_board_images = MagicMock()
mock_board_images.get_all_board_image_names_for_board.return_value = []
mock_invoker.services.board_images = mock_board_images
mock_deps = MockApiDependencies(mock_invoker)
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", mock_deps)
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps)
monkeypatch.setattr("invokeai.app.api.routers.boards.ApiDependencies", mock_deps)
yield
@pytest.fixture
def admin_token(setup_jwt_secret: None, enable_multiuser_for_tests: Any, mock_invoker: Invoker, client: TestClient):
"""Create an admin user and return a login token."""
setup_test_user(mock_invoker, "admin@test.com", "Test Admin", is_admin=True)
return get_user_token(client, "admin@test.com")
@pytest.fixture
def user1_token(enable_multiuser_for_tests: Any, mock_invoker: Invoker, client: TestClient, admin_token: str):
"""Create a regular user and return a login token."""
setup_test_user(mock_invoker, "user1@test.com", "User One", is_admin=False)
return get_user_token(client, "user1@test.com")
@pytest.fixture
def user2_token(enable_multiuser_for_tests: Any, mock_invoker: Invoker, client: TestClient, admin_token: str):
"""Create a second regular user and return a login token."""
setup_test_user(mock_invoker, "user2@test.com", "User Two", is_admin=False)
return get_user_token(client, "user2@test.com")
# ---------------------------------------------------------------------------
# Basic auth requirement tests
# ---------------------------------------------------------------------------
def test_create_board_requires_auth(enable_multiuser_for_tests: Any, client: TestClient):
"""Test that creating a board requires authentication."""
response = client.post("/api/v1/boards/?board_name=Test+Board")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_list_boards_requires_auth(enable_multiuser_for_tests: Any, client: TestClient):
"""Test that listing boards requires authentication."""
response = client.get("/api/v1/boards/?all=true")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_get_board_requires_auth(enable_multiuser_for_tests: Any, client: TestClient):
"""Test that getting a board requires authentication."""
response = client.get("/api/v1/boards/some-board-id")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_update_board_requires_auth(enable_multiuser_for_tests: Any, client: TestClient):
"""Test that updating a board requires authentication."""
response = client.patch("/api/v1/boards/some-board-id", json={"board_name": "New Name"})
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_delete_board_requires_auth(enable_multiuser_for_tests: Any, client: TestClient):
"""Test that deleting a board requires authentication."""
response = client.delete("/api/v1/boards/some-board-id")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_list_board_image_names_requires_auth(enable_multiuser_for_tests: Any, client: TestClient):
"""Test that listing board image names requires authentication."""
response = client.get("/api/v1/boards/some-board-id/image_names")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
# ---------------------------------------------------------------------------
# Basic create / list tests
# ---------------------------------------------------------------------------
def test_create_board_with_auth(client: TestClient, admin_token: str):
"""Test that authenticated users can create boards."""
response = client.post(
"/api/v1/boards/?board_name=My+Test+Board",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == status.HTTP_201_CREATED
data = response.json()
assert data["board_name"] == "My Test Board"
assert "board_id" in data
def test_list_boards_with_auth(client: TestClient, admin_token: str):
"""Test that authenticated users can list their boards."""
# First create a board
client.post(
"/api/v1/boards/?board_name=Listed+Board",
headers={"Authorization": f"Bearer {admin_token}"},
)
# Now list boards
response = client.get(
"/api/v1/boards/?all=true",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == status.HTTP_200_OK
boards = response.json()
assert isinstance(boards, list)
board_names = [b["board_name"] for b in boards]
assert "Listed Board" in board_names
def test_user_boards_are_isolated(client: TestClient, admin_token: str, user1_token: str):
"""Test that boards are isolated between users."""
# Admin creates a board
admin_response = client.post(
"/api/v1/boards/?board_name=Admin+Board",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert admin_response.status_code == status.HTTP_201_CREATED
# Admin can see their own board
list_response = client.get(
"/api/v1/boards/?all=true",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert list_response.status_code == status.HTTP_200_OK
boards = list_response.json()
board_names = [b["board_name"] for b in boards]
assert "Admin Board" in board_names
# user1 should not see admin's board in their own listing
user1_list = client.get(
"/api/v1/boards/?all=true",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert user1_list.status_code == status.HTTP_200_OK
user1_board_names = [b["board_name"] for b in user1_list.json()]
assert "Admin Board" not in user1_board_names
# ---------------------------------------------------------------------------
# Ownership enforcement: get_board
# ---------------------------------------------------------------------------
def test_get_board_owner_succeeds(client: TestClient, user1_token: str):
"""Test that the board owner can retrieve their own board."""
create = client.post(
"/api/v1/boards/?board_name=User1+Board",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert create.status_code == status.HTTP_201_CREATED
board_id = create.json()["board_id"]
response = client.get(
f"/api/v1/boards/{board_id}",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["board_id"] == board_id
def test_get_board_other_user_forbidden(client: TestClient, user1_token: str, user2_token: str):
"""Test that a non-owner cannot retrieve another user's board."""
create = client.post(
"/api/v1/boards/?board_name=User1+Private+Board",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert create.status_code == status.HTTP_201_CREATED
board_id = create.json()["board_id"]
response = client.get(
f"/api/v1/boards/{board_id}",
headers={"Authorization": f"Bearer {user2_token}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_get_board_admin_can_access_any_board(client: TestClient, admin_token: str, user1_token: str):
"""Test that an admin can retrieve any user's board."""
create = client.post(
"/api/v1/boards/?board_name=User1+Board+For+Admin",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert create.status_code == status.HTTP_201_CREATED
board_id = create.json()["board_id"]
response = client.get(
f"/api/v1/boards/{board_id}",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == status.HTTP_200_OK
# ---------------------------------------------------------------------------
# Ownership enforcement: update_board
# ---------------------------------------------------------------------------
def test_update_board_owner_succeeds(client: TestClient, user1_token: str):
"""Test that the board owner can update their own board."""
create = client.post(
"/api/v1/boards/?board_name=Original+Name",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert create.status_code == status.HTTP_201_CREATED
board_id = create.json()["board_id"]
response = client.patch(
f"/api/v1/boards/{board_id}",
json={"board_name": "Updated Name"},
headers={"Authorization": f"Bearer {user1_token}"},
)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["board_name"] == "Updated Name"
def test_update_board_other_user_forbidden(client: TestClient, user1_token: str, user2_token: str):
"""Test that a non-owner cannot update another user's board."""
create = client.post(
"/api/v1/boards/?board_name=User1+Board+To+Update",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert create.status_code == status.HTTP_201_CREATED
board_id = create.json()["board_id"]
response = client.patch(
f"/api/v1/boards/{board_id}",
json={"board_name": "Hijacked Name"},
headers={"Authorization": f"Bearer {user2_token}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_update_board_admin_can_update_any_board(client: TestClient, admin_token: str, user1_token: str):
"""Test that an admin can update any user's board."""
create = client.post(
"/api/v1/boards/?board_name=User1+Board+Admin+Update",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert create.status_code == status.HTTP_201_CREATED
board_id = create.json()["board_id"]
response = client.patch(
f"/api/v1/boards/{board_id}",
json={"board_name": "Admin Updated Name"},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["board_name"] == "Admin Updated Name"
# ---------------------------------------------------------------------------
# Ownership enforcement: delete_board
# ---------------------------------------------------------------------------
def test_delete_board_owner_succeeds(client: TestClient, user1_token: str):
"""Test that the board owner can delete their own board."""
create = client.post(
"/api/v1/boards/?board_name=Board+To+Delete",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert create.status_code == status.HTTP_201_CREATED
board_id = create.json()["board_id"]
response = client.delete(
f"/api/v1/boards/{board_id}",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["board_id"] == board_id
def test_delete_board_other_user_forbidden(client: TestClient, user1_token: str, user2_token: str):
"""Test that a non-owner cannot delete another user's board."""
create = client.post(
"/api/v1/boards/?board_name=User1+Board+To+Delete",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert create.status_code == status.HTTP_201_CREATED
board_id = create.json()["board_id"]
response = client.delete(
f"/api/v1/boards/{board_id}",
headers={"Authorization": f"Bearer {user2_token}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_delete_board_admin_can_delete_any_board(client: TestClient, admin_token: str, user1_token: str):
"""Test that an admin can delete any user's board."""
create = client.post(
"/api/v1/boards/?board_name=User1+Board+Admin+Delete",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert create.status_code == status.HTTP_201_CREATED
board_id = create.json()["board_id"]
response = client.delete(
f"/api/v1/boards/{board_id}",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == status.HTTP_200_OK
# ---------------------------------------------------------------------------
# Ownership enforcement: list_all_board_image_names
# ---------------------------------------------------------------------------
def test_list_board_image_names_owner_succeeds(client: TestClient, user1_token: str):
"""Test that the board owner can list image names for their board."""
create = client.post(
"/api/v1/boards/?board_name=User1+Images+Board",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert create.status_code == status.HTTP_201_CREATED
board_id = create.json()["board_id"]
response = client.get(
f"/api/v1/boards/{board_id}/image_names",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert response.status_code == status.HTTP_200_OK
assert isinstance(response.json(), list)
def test_list_board_image_names_other_user_forbidden(client: TestClient, user1_token: str, user2_token: str):
"""Test that a non-owner cannot list image names for another user's board."""
create = client.post(
"/api/v1/boards/?board_name=User1+Private+Images+Board",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert create.status_code == status.HTTP_201_CREATED
board_id = create.json()["board_id"]
response = client.get(
f"/api/v1/boards/{board_id}/image_names",
headers={"Authorization": f"Bearer {user2_token}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_list_board_image_names_admin_can_access_any_board(client: TestClient, admin_token: str, user1_token: str):
"""Test that an admin can list image names for any user's board."""
create = client.post(
"/api/v1/boards/?board_name=User1+Board+Admin+Images",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert create.status_code == status.HTTP_201_CREATED
board_id = create.json()["board_id"]
response = client.get(
f"/api/v1/boards/{board_id}/image_names",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == status.HTTP_200_OK
def test_list_board_image_names_none_board_no_auth_check(enable_multiuser_for_tests: Any, client: TestClient):
"""Test that listing image names for the 'none' board requires auth but no ownership check."""
# The 'none' board is the uncategorized images board — no ownership check needed,
# but auth is still required in multiuser mode.
response = client.get("/api/v1/boards/none/image_names")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
# ---------------------------------------------------------------------------
# Misc tests
# ---------------------------------------------------------------------------
def test_enqueue_batch_requires_auth(enable_multiuser_for_tests: Any, client: TestClient):
"""Test that enqueuing a batch requires authentication."""
response = client.post(
"/api/v1/queue/default/enqueue_batch",
json={
"batch": {
"batch_id": "test-batch",
"data": [],
"graph": {"nodes": {}, "edges": []},
},
"prepend": False,
},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
# ---------------------------------------------------------------------------
# Board visibility tests
# ---------------------------------------------------------------------------
def test_board_created_with_private_visibility(client: TestClient, user1_token: str):
"""Test that newly created boards default to private visibility."""
create = client.post(
"/api/v1/boards/?board_name=Visibility+Default+Board",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert create.status_code == status.HTTP_201_CREATED
data = create.json()
assert data["board_visibility"] == "private"
def test_set_board_visibility_shared(client: TestClient, user1_token: str):
"""Test that the board owner can set their board to shared."""
create = client.post(
"/api/v1/boards/?board_name=Shared+Board",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert create.status_code == status.HTTP_201_CREATED
board_id = create.json()["board_id"]
response = client.patch(
f"/api/v1/boards/{board_id}",
json={"board_visibility": "shared"},
headers={"Authorization": f"Bearer {user1_token}"},
)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["board_visibility"] == "shared"
def test_set_board_visibility_public(client: TestClient, user1_token: str):
"""Test that the board owner can set their board to public."""
create = client.post(
"/api/v1/boards/?board_name=Public+Board",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert create.status_code == status.HTTP_201_CREATED
board_id = create.json()["board_id"]
response = client.patch(
f"/api/v1/boards/{board_id}",
json={"board_visibility": "public"},
headers={"Authorization": f"Bearer {user1_token}"},
)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["board_visibility"] == "public"
def test_shared_board_visible_to_other_users(client: TestClient, user1_token: str, user2_token: str):
"""Test that a shared board is accessible to other authenticated users."""
# user1 creates a board and sets it to shared
create = client.post(
"/api/v1/boards/?board_name=User1+Shared+Board",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert create.status_code == status.HTTP_201_CREATED
board_id = create.json()["board_id"]
client.patch(
f"/api/v1/boards/{board_id}",
json={"board_visibility": "shared"},
headers={"Authorization": f"Bearer {user1_token}"},
)
# user2 should be able to access the shared board
response = client.get(
f"/api/v1/boards/{board_id}",
headers={"Authorization": f"Bearer {user2_token}"},
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["board_id"] == board_id
def test_public_board_visible_to_other_users(client: TestClient, user1_token: str, user2_token: str):
"""Test that a public board is accessible to other authenticated users."""
# user1 creates a board and sets it to public
create = client.post(
"/api/v1/boards/?board_name=User1+Public+Board",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert create.status_code == status.HTTP_201_CREATED
board_id = create.json()["board_id"]
client.patch(
f"/api/v1/boards/{board_id}",
json={"board_visibility": "public"},
headers={"Authorization": f"Bearer {user1_token}"},
)
# user2 should be able to access the public board
response = client.get(
f"/api/v1/boards/{board_id}",
headers={"Authorization": f"Bearer {user2_token}"},
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["board_id"] == board_id
def test_shared_board_appears_in_other_user_list(client: TestClient, user1_token: str, user2_token: str):
"""Test that shared boards appear in other users' board listings."""
# user1 creates and shares a board
create = client.post(
"/api/v1/boards/?board_name=User1+Listed+Shared+Board",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert create.status_code == status.HTTP_201_CREATED
board_id = create.json()["board_id"]
client.patch(
f"/api/v1/boards/{board_id}",
json={"board_visibility": "shared"},
headers={"Authorization": f"Bearer {user1_token}"},
)
# user2 should see the shared board in their listing
response = client.get(
"/api/v1/boards/?all=true",
headers={"Authorization": f"Bearer {user2_token}"},
)
assert response.status_code == status.HTTP_200_OK
board_ids = [b["board_id"] for b in response.json()]
assert board_id in board_ids
def test_private_board_not_visible_after_privacy_change(client: TestClient, user1_token: str, user2_token: str):
"""Test that reverting a board from shared to private hides it from other users."""
# user1 creates a board, makes it shared, then reverts to private
create = client.post(
"/api/v1/boards/?board_name=Reverted+Board",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert create.status_code == status.HTTP_201_CREATED
board_id = create.json()["board_id"]
client.patch(
f"/api/v1/boards/{board_id}",
json={"board_visibility": "shared"},
headers={"Authorization": f"Bearer {user1_token}"},
)
client.patch(
f"/api/v1/boards/{board_id}",
json={"board_visibility": "private"},
headers={"Authorization": f"Bearer {user1_token}"},
)
# user2 should not be able to access the now-private board
response = client.get(
f"/api/v1/boards/{board_id}",
headers={"Authorization": f"Bearer {user2_token}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_non_owner_cannot_change_board_visibility(client: TestClient, user1_token: str, user2_token: str):
"""Test that a non-owner cannot change a board's visibility."""
# user1 creates a board
create = client.post(
"/api/v1/boards/?board_name=User1+Private+Locked+Board",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert create.status_code == status.HTTP_201_CREATED
board_id = create.json()["board_id"]
# user2 tries to make it public - should be forbidden
response = client.patch(
f"/api/v1/boards/{board_id}",
json={"board_visibility": "public"},
headers={"Authorization": f"Bearer {user2_token}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_shared_board_image_names_visible_to_other_users(client: TestClient, user1_token: str, user2_token: str):
"""Test that image names for shared boards are accessible to other users."""
create = client.post(
"/api/v1/boards/?board_name=User1+Shared+Images+Board",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert create.status_code == status.HTTP_201_CREATED
board_id = create.json()["board_id"]
client.patch(
f"/api/v1/boards/{board_id}",
json={"board_visibility": "shared"},
headers={"Authorization": f"Bearer {user1_token}"},
)
# user2 can access image names for a shared board
response = client.get(
f"/api/v1/boards/{board_id}/image_names",
headers={"Authorization": f"Bearer {user2_token}"},
)
assert response.status_code == status.HTTP_200_OK
def test_admin_can_change_any_board_visibility(client: TestClient, admin_token: str, user1_token: str):
"""Test that an admin can change the visibility of any user's board."""
create = client.post(
"/api/v1/boards/?board_name=User1+Board+For+Admin+Visibility",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert create.status_code == status.HTTP_201_CREATED
board_id = create.json()["board_id"]
# Admin sets it to public
response = client.patch(
f"/api/v1/boards/{board_id}",
json={"board_visibility": "public"},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["board_visibility"] == "public"
@@ -0,0 +1,444 @@
"""Tests for multiuser client state functionality."""
from typing import Any
import pytest
from fastapi import status
from fastapi.testclient import TestClient
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.api_app import app
from invokeai.app.services.invoker import Invoker
from invokeai.app.services.users.users_common import UserCreateRequest
@pytest.fixture
def client():
"""Create a test client."""
return TestClient(app)
class MockApiDependencies(ApiDependencies):
"""Mock API dependencies for testing."""
invoker: Invoker
def __init__(self, invoker: Invoker) -> None:
self.invoker = invoker
def setup_test_user(
mock_invoker: Invoker, email: str, display_name: str, password: str = "TestPass123", is_admin: bool = False
) -> str:
"""Helper to create a test user and return user_id."""
user_service = mock_invoker.services.users
user_data = UserCreateRequest(
email=email,
display_name=display_name,
password=password,
is_admin=is_admin,
)
user = user_service.create(user_data)
return user.user_id
def get_user_token(client: TestClient, email: str, password: str = "TestPass123") -> str:
"""Helper to login and get a user token."""
response = client.post(
"/api/v1/auth/login",
json={
"email": email,
"password": password,
"remember_me": False,
},
)
assert response.status_code == 200
return response.json()["token"]
@pytest.fixture
def admin_token(monkeypatch: Any, mock_invoker: Invoker, client: TestClient):
"""Get an admin token for testing."""
# Enable multiuser mode for auth endpoints
mock_invoker.services.configuration.multiuser = True
# Mock ApiDependencies for auth and client_state routers
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
monkeypatch.setattr("invokeai.app.api.routers.client_state.ApiDependencies", MockApiDependencies(mock_invoker))
# Create admin user
setup_test_user(mock_invoker, "admin@test.com", "Admin User", is_admin=True)
return get_user_token(client, "admin@test.com")
@pytest.fixture
def user1_token(monkeypatch: Any, mock_invoker: Invoker, client: TestClient, admin_token: str):
"""Get a token for test user 1."""
# Create a regular user
setup_test_user(mock_invoker, "user1@test.com", "User One", is_admin=False)
return get_user_token(client, "user1@test.com")
@pytest.fixture
def user2_token(monkeypatch: Any, mock_invoker: Invoker, client: TestClient, admin_token: str):
"""Get a token for test user 2."""
# Create another regular user
setup_test_user(mock_invoker, "user2@test.com", "User Two", is_admin=False)
return get_user_token(client, "user2@test.com")
def test_get_client_state_without_auth_uses_system_user(client: TestClient, monkeypatch, mock_invoker: Invoker):
"""Test that getting client state without authentication uses the system user."""
# Mock ApiDependencies
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
monkeypatch.setattr("invokeai.app.api.routers.client_state.ApiDependencies", MockApiDependencies(mock_invoker))
# Set a value for the system user directly
mock_invoker.services.client_state_persistence.set_by_key("system", "test_key", "system_value")
# Get without authentication - should return system user's value
response = client.get("/api/v1/client_state/default/get_by_key?key=test_key")
assert response.status_code == status.HTTP_200_OK
assert response.json() == "system_value"
def test_set_client_state_without_auth_uses_system_user(client: TestClient, monkeypatch, mock_invoker: Invoker):
"""Test that setting client state without authentication uses the system user."""
# Mock ApiDependencies
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
monkeypatch.setattr("invokeai.app.api.routers.client_state.ApiDependencies", MockApiDependencies(mock_invoker))
# Set without authentication - should set for system user
response = client.post(
"/api/v1/client_state/default/set_by_key?key=test_key",
json="unauthenticated_value",
)
assert response.status_code == status.HTTP_200_OK
# Verify it was set for system user
value = mock_invoker.services.client_state_persistence.get_by_key("system", "test_key")
assert value == "unauthenticated_value"
def test_delete_client_state_without_auth_uses_system_user(client: TestClient, monkeypatch, mock_invoker: Invoker):
"""Test that deleting client state without authentication uses the system user."""
# Mock ApiDependencies
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
monkeypatch.setattr("invokeai.app.api.routers.client_state.ApiDependencies", MockApiDependencies(mock_invoker))
# Set a value for system user
mock_invoker.services.client_state_persistence.set_by_key("system", "test_key", "system_value")
# Delete without authentication - should delete system user's data
response = client.post("/api/v1/client_state/default/delete")
assert response.status_code == status.HTTP_200_OK
# Verify it was deleted for system user
value = mock_invoker.services.client_state_persistence.get_by_key("system", "test_key")
assert value is None
def test_set_and_get_client_state(client: TestClient, admin_token: str):
"""Test that authenticated users can set and get their client state."""
# Set a value
set_response = client.post(
"/api/v1/client_state/default/set_by_key?key=test_key",
json="test_value",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert set_response.status_code == status.HTTP_200_OK
assert set_response.json() == "test_value"
# Get the value back
get_response = client.get(
"/api/v1/client_state/default/get_by_key?key=test_key",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert get_response.status_code == status.HTTP_200_OK
assert get_response.json() == "test_value"
def test_client_state_isolation_between_users(client: TestClient, user1_token: str, user2_token: str):
"""Test that client state is isolated between different users."""
# User 1 sets a value
user1_set_response = client.post(
"/api/v1/client_state/default/set_by_key?key=shared_key",
json="user1_value",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert user1_set_response.status_code == status.HTTP_200_OK
# User 2 sets a different value for the same key
user2_set_response = client.post(
"/api/v1/client_state/default/set_by_key?key=shared_key",
json="user2_value",
headers={"Authorization": f"Bearer {user2_token}"},
)
assert user2_set_response.status_code == status.HTTP_200_OK
# User 1 should still see their own value
user1_get_response = client.get(
"/api/v1/client_state/default/get_by_key?key=shared_key",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert user1_get_response.status_code == status.HTTP_200_OK
assert user1_get_response.json() == "user1_value"
# User 2 should see their own value
user2_get_response = client.get(
"/api/v1/client_state/default/get_by_key?key=shared_key",
headers={"Authorization": f"Bearer {user2_token}"},
)
assert user2_get_response.status_code == status.HTTP_200_OK
assert user2_get_response.json() == "user2_value"
def test_get_nonexistent_key_returns_null(client: TestClient, admin_token: str):
"""Test that getting a nonexistent key returns null."""
response = client.get(
"/api/v1/client_state/default/get_by_key?key=nonexistent_key",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == status.HTTP_200_OK
assert response.json() is None
def test_delete_client_state(client: TestClient, admin_token: str):
"""Test that users can delete their own client state."""
# Set some values
client.post(
"/api/v1/client_state/default/set_by_key?key=key1",
json="value1",
headers={"Authorization": f"Bearer {admin_token}"},
)
client.post(
"/api/v1/client_state/default/set_by_key?key=key2",
json="value2",
headers={"Authorization": f"Bearer {admin_token}"},
)
# Verify values exist
get_response = client.get(
"/api/v1/client_state/default/get_by_key?key=key1",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert get_response.json() == "value1"
# Delete all client state
delete_response = client.post(
"/api/v1/client_state/default/delete",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert delete_response.status_code == status.HTTP_200_OK
# Verify values are gone
get_response = client.get(
"/api/v1/client_state/default/get_by_key?key=key1",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert get_response.json() is None
get_response = client.get(
"/api/v1/client_state/default/get_by_key?key=key2",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert get_response.json() is None
def test_update_existing_key(client: TestClient, admin_token: str):
"""Test that updating an existing key works correctly."""
# Set initial value
client.post(
"/api/v1/client_state/default/set_by_key?key=update_key",
json="initial_value",
headers={"Authorization": f"Bearer {admin_token}"},
)
# Update the value
update_response = client.post(
"/api/v1/client_state/default/set_by_key?key=update_key",
json="updated_value",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert update_response.status_code == status.HTTP_200_OK
# Verify the updated value
get_response = client.get(
"/api/v1/client_state/default/get_by_key?key=update_key",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert get_response.status_code == status.HTTP_200_OK
assert get_response.json() == "updated_value"
def test_complex_json_values(client: TestClient, admin_token: str):
"""Test that complex JSON values can be stored and retrieved."""
import json
complex_dict = {"params": {"model": "test-model", "steps": 50}, "prompt": "a beautiful landscape"}
complex_value = json.dumps(complex_dict)
# Set complex value
set_response = client.post(
"/api/v1/client_state/default/set_by_key?key=complex_key",
json=complex_value,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert set_response.status_code == status.HTTP_200_OK
# Get it back
get_response = client.get(
"/api/v1/client_state/default/get_by_key?key=complex_key",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert get_response.status_code == status.HTTP_200_OK
assert get_response.json() == complex_value
def test_get_keys_by_prefix_without_auth(client: TestClient, monkeypatch, mock_invoker: Invoker):
"""Test that keys can be retrieved by prefix without authentication."""
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
monkeypatch.setattr("invokeai.app.api.routers.client_state.ApiDependencies", MockApiDependencies(mock_invoker))
# Set several keys with a common prefix directly
for i in range(3):
mock_invoker.services.client_state_persistence.set_by_key("system", f"canvas_snapshot:snap{i}", f"value{i}")
mock_invoker.services.client_state_persistence.set_by_key("system", "other_key", "other_value")
# Get keys by prefix
response = client.get("/api/v1/client_state/default/get_keys_by_prefix?prefix=canvas_snapshot:")
assert response.status_code == status.HTTP_200_OK
keys = response.json()
assert len(keys) == 3
assert "canvas_snapshot:snap0" in keys
assert "canvas_snapshot:snap1" in keys
assert "canvas_snapshot:snap2" in keys
assert "other_key" not in keys
def test_get_keys_by_prefix_empty_without_auth(client: TestClient, monkeypatch, mock_invoker: Invoker):
"""Test that an empty list is returned when no keys match the prefix."""
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
monkeypatch.setattr("invokeai.app.api.routers.client_state.ApiDependencies", MockApiDependencies(mock_invoker))
response = client.get("/api/v1/client_state/default/get_keys_by_prefix?prefix=nonexistent_prefix:")
assert response.status_code == status.HTTP_200_OK
assert response.json() == []
def test_delete_by_key_without_auth(client: TestClient, monkeypatch, mock_invoker: Invoker):
"""Test that a specific key can be deleted without affecting other keys."""
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
monkeypatch.setattr("invokeai.app.api.routers.client_state.ApiDependencies", MockApiDependencies(mock_invoker))
# Set two keys directly
mock_invoker.services.client_state_persistence.set_by_key("system", "keep_key", "keep_value")
mock_invoker.services.client_state_persistence.set_by_key("system", "delete_key", "delete_value")
# Delete only one key via endpoint
delete_response = client.post("/api/v1/client_state/default/delete_by_key?key=delete_key")
assert delete_response.status_code == status.HTTP_200_OK
# Verify deleted key is gone
value = mock_invoker.services.client_state_persistence.get_by_key("system", "delete_key")
assert value is None
# Verify other key still exists
value = mock_invoker.services.client_state_persistence.get_by_key("system", "keep_key")
assert value == "keep_value"
def test_get_keys_by_prefix(client: TestClient, admin_token: str):
"""Test that keys can be retrieved by prefix with authentication."""
# Set several keys with a common prefix
for i in range(3):
client.post(
f"/api/v1/client_state/default/set_by_key?key=canvas_snapshot:snap{i}",
json=f"value{i}",
headers={"Authorization": f"Bearer {admin_token}"},
)
# Set a key without the prefix
client.post(
"/api/v1/client_state/default/set_by_key?key=other_key",
json="other_value",
headers={"Authorization": f"Bearer {admin_token}"},
)
# Get keys by prefix
response = client.get(
"/api/v1/client_state/default/get_keys_by_prefix?prefix=canvas_snapshot:",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == status.HTTP_200_OK
keys = response.json()
assert len(keys) == 3
assert "canvas_snapshot:snap0" in keys
assert "canvas_snapshot:snap1" in keys
assert "canvas_snapshot:snap2" in keys
assert "other_key" not in keys
def test_delete_by_key(client: TestClient, admin_token: str):
"""Test that a specific key can be deleted without affecting other keys."""
# Set two keys
client.post(
"/api/v1/client_state/default/set_by_key?key=keep_key",
json="keep_value",
headers={"Authorization": f"Bearer {admin_token}"},
)
client.post(
"/api/v1/client_state/default/set_by_key?key=delete_key",
json="delete_value",
headers={"Authorization": f"Bearer {admin_token}"},
)
# Delete only one key
delete_response = client.post(
"/api/v1/client_state/default/delete_by_key?key=delete_key",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert delete_response.status_code == status.HTTP_200_OK
# Verify deleted key is gone
get_response = client.get(
"/api/v1/client_state/default/get_by_key?key=delete_key",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert get_response.json() is None
# Verify other key still exists
get_response = client.get(
"/api/v1/client_state/default/get_by_key?key=keep_key",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert get_response.json() == "keep_value"
def test_get_keys_by_prefix_isolation_between_users(client: TestClient, user1_token: str, user2_token: str):
"""Test that get_keys_by_prefix is isolated between users."""
# User 1 sets keys
client.post(
"/api/v1/client_state/default/set_by_key?key=snapshot:u1",
json="user1_data",
headers={"Authorization": f"Bearer {user1_token}"},
)
# User 2 sets keys
client.post(
"/api/v1/client_state/default/set_by_key?key=snapshot:u2",
json="user2_data",
headers={"Authorization": f"Bearer {user2_token}"},
)
# User 1 should only see their own keys
response = client.get(
"/api/v1/client_state/default/get_keys_by_prefix?prefix=snapshot:",
headers={"Authorization": f"Bearer {user1_token}"},
)
keys = response.json()
assert "snapshot:u1" in keys
assert "snapshot:u2" not in keys
+499
View File
@@ -0,0 +1,499 @@
"""Tests for the custom nodes router."""
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
from invokeai.app.api.routers.custom_nodes import (
PACK_MANIFEST_FILENAME,
_get_installed_packs,
_import_workflows_from_pack,
_load_node_pack,
_purge_pack_modules,
_read_pack_manifest,
_remove_workflows_by_ids,
_write_pack_manifest,
)
from invokeai.app.invocations.baseinvocation import (
BaseInvocation,
InvocationRegistry,
)
class TestGetInstalledPacks:
"""Tests for _get_installed_packs()."""
def test_returns_empty_when_dir_not_exists(self, tmp_path: Path) -> None:
nonexistent = tmp_path / "nonexistent"
with patch("invokeai.app.api.routers.custom_nodes._get_custom_nodes_path", return_value=nonexistent):
packs = _get_installed_packs()
assert packs == []
def test_returns_empty_when_dir_empty(self, tmp_path: Path) -> None:
with patch("invokeai.app.api.routers.custom_nodes._get_custom_nodes_path", return_value=tmp_path):
packs = _get_installed_packs()
assert packs == []
def test_skips_files(self, tmp_path: Path) -> None:
(tmp_path / "some_file.py").touch()
with patch("invokeai.app.api.routers.custom_nodes._get_custom_nodes_path", return_value=tmp_path):
packs = _get_installed_packs()
assert packs == []
def test_skips_hidden_dirs(self, tmp_path: Path) -> None:
hidden = tmp_path / ".hidden_pack"
hidden.mkdir()
(hidden / "__init__.py").touch()
with patch("invokeai.app.api.routers.custom_nodes._get_custom_nodes_path", return_value=tmp_path):
packs = _get_installed_packs()
assert packs == []
def test_skips_dirs_without_init(self, tmp_path: Path) -> None:
no_init = tmp_path / "no_init_pack"
no_init.mkdir()
with patch("invokeai.app.api.routers.custom_nodes._get_custom_nodes_path", return_value=tmp_path):
packs = _get_installed_packs()
assert packs == []
def test_finds_valid_pack(self, tmp_path: Path) -> None:
pack = tmp_path / "my_pack"
pack.mkdir()
(pack / "__init__.py").touch()
with patch("invokeai.app.api.routers.custom_nodes._get_custom_nodes_path", return_value=tmp_path):
packs = _get_installed_packs()
assert len(packs) == 1
assert packs[0].name == "my_pack"
assert packs[0].path == str(pack)
def test_finds_multiple_packs_sorted(self, tmp_path: Path) -> None:
for name in ["zebra_pack", "alpha_pack", "middle_pack"]:
d = tmp_path / name
d.mkdir()
(d / "__init__.py").touch()
with patch("invokeai.app.api.routers.custom_nodes._get_custom_nodes_path", return_value=tmp_path):
packs = _get_installed_packs()
assert len(packs) == 3
assert [p.name for p in packs] == ["alpha_pack", "middle_pack", "zebra_pack"]
class TestImportWorkflowsFromPack:
"""Tests for _import_workflows_from_pack()."""
@staticmethod
def _mock_service_with_id(workflow_id: str = "new-id") -> MagicMock:
"""Returns a mock workflow_records service whose create() yields a DTO with the given id."""
mock_service = MagicMock()
mock_service.create.return_value = MagicMock(workflow_id=workflow_id)
return mock_service
def test_no_json_files(self, tmp_path: Path) -> None:
(tmp_path / "__init__.py").touch()
(tmp_path / "node.py").write_text("# node code")
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies"):
ids = _import_workflows_from_pack(tmp_path, "test_pack", owner_user_id="admin")
assert ids == []
def test_skips_non_workflow_json(self, tmp_path: Path) -> None:
# JSON without nodes/edges should be skipped
config = {"setting": "value"}
(tmp_path / "config.json").write_text(json.dumps(config))
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies"):
ids = _import_workflows_from_pack(tmp_path, "test_pack", owner_user_id="admin")
assert ids == []
def test_imports_valid_workflow(self, tmp_path: Path) -> None:
workflow = {
"name": "Test Workflow",
"author": "Test",
"description": "A test workflow",
"version": "1.0.0",
"contact": "",
"tags": "test",
"notes": "",
"exposedFields": [],
"meta": {"version": "3.0.0", "category": "user"},
"nodes": [{"id": "1", "type": "test_node"}],
"edges": [],
}
workflows_dir = tmp_path / "workflows"
workflows_dir.mkdir()
(workflows_dir / "test_workflow.json").write_text(json.dumps(workflow))
mock_service = self._mock_service_with_id("wf-new-1")
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies") as mock_deps:
mock_deps.invoker.services.workflow_records = mock_service
ids = _import_workflows_from_pack(tmp_path, "test_pack", owner_user_id="admin")
assert ids == ["wf-new-1"]
mock_service.create.assert_called_once()
# Verify the workflow was tagged
create_kwargs = mock_service.create.call_args.kwargs
assert "node-pack:test_pack" in create_kwargs["workflow"].tags
assert create_kwargs["user_id"] == "admin"
assert create_kwargs["is_public"] is True
def test_adds_pack_tag_to_existing_tags(self, tmp_path: Path) -> None:
workflow = {
"name": "Tagged Workflow",
"author": "Test",
"description": "",
"version": "1.0.0",
"contact": "",
"tags": "existing, tags",
"notes": "",
"exposedFields": [],
"meta": {"version": "3.0.0", "category": "user"},
"nodes": [{"id": "1"}],
"edges": [],
}
(tmp_path / "workflow.json").write_text(json.dumps(workflow))
mock_service = self._mock_service_with_id()
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies") as mock_deps:
mock_deps.invoker.services.workflow_records = mock_service
ids = _import_workflows_from_pack(tmp_path, "my_pack", owner_user_id="admin")
assert len(ids) == 1
created_workflow = mock_service.create.call_args.kwargs["workflow"]
assert "existing, tags" in created_workflow.tags
assert "node-pack:my_pack" in created_workflow.tags
def test_removes_id_before_import(self, tmp_path: Path) -> None:
workflow = {
"id": "should-be-removed",
"name": "Workflow With ID",
"author": "Test",
"description": "",
"version": "1.0.0",
"contact": "",
"tags": "",
"notes": "",
"exposedFields": [],
"meta": {"version": "3.0.0", "category": "user"},
"nodes": [],
"edges": [],
}
(tmp_path / "workflow.json").write_text(json.dumps(workflow))
mock_service = self._mock_service_with_id()
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies") as mock_deps:
mock_deps.invoker.services.workflow_records = mock_service
ids = _import_workflows_from_pack(tmp_path, "test_pack", owner_user_id="admin")
assert len(ids) == 1
def test_sets_category_to_user(self, tmp_path: Path) -> None:
workflow = {
"name": "Default-like Workflow",
"author": "Test",
"description": "",
"version": "1.0.0",
"contact": "",
"tags": "",
"notes": "",
"exposedFields": [],
"meta": {"version": "3.0.0", "category": "default"},
"nodes": [],
"edges": [],
}
(tmp_path / "workflow.json").write_text(json.dumps(workflow))
mock_service = self._mock_service_with_id()
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies") as mock_deps:
mock_deps.invoker.services.workflow_records = mock_service
ids = _import_workflows_from_pack(tmp_path, "test_pack", owner_user_id="admin")
assert len(ids) == 1
created_workflow = mock_service.create.call_args.kwargs["workflow"]
assert created_workflow.meta.category.value == "user"
def test_skips_invalid_json(self, tmp_path: Path) -> None:
(tmp_path / "broken.json").write_text("{invalid json")
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies"):
ids = _import_workflows_from_pack(tmp_path, "test_pack", owner_user_id="admin")
assert ids == []
def test_finds_workflows_recursively(self, tmp_path: Path) -> None:
workflow = {
"name": "Nested Workflow",
"author": "Test",
"description": "",
"version": "1.0.0",
"contact": "",
"tags": "",
"notes": "",
"exposedFields": [],
"meta": {"version": "3.0.0", "category": "user"},
"nodes": [{"id": "1"}],
"edges": [],
}
nested = tmp_path / "sub" / "dir"
nested.mkdir(parents=True)
(nested / "deep_workflow.json").write_text(json.dumps(workflow))
mock_service = self._mock_service_with_id()
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies") as mock_deps:
mock_deps.invoker.services.workflow_records = mock_service
ids = _import_workflows_from_pack(tmp_path, "test_pack", owner_user_id="admin")
assert len(ids) == 1
def test_skips_manifest_file(self, tmp_path: Path) -> None:
# A manifest inside the pack must not be mistaken for a workflow during import
(tmp_path / PACK_MANIFEST_FILENAME).write_text(json.dumps({"workflow_ids": ["wf-old"]}))
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies"):
ids = _import_workflows_from_pack(tmp_path, "test_pack", owner_user_id="admin")
assert ids == []
class TestPackManifest:
"""Tests for _write_pack_manifest() and _read_pack_manifest()."""
def test_write_then_read_roundtrip(self, tmp_path: Path) -> None:
_write_pack_manifest(tmp_path, ["wf-1", "wf-2"])
assert _read_pack_manifest(tmp_path) == ["wf-1", "wf-2"]
def test_read_returns_empty_when_manifest_missing(self, tmp_path: Path) -> None:
assert _read_pack_manifest(tmp_path) == []
def test_read_returns_empty_when_manifest_malformed(self, tmp_path: Path) -> None:
(tmp_path / PACK_MANIFEST_FILENAME).write_text("{not valid json")
assert _read_pack_manifest(tmp_path) == []
def test_read_returns_empty_when_workflow_ids_not_a_list(self, tmp_path: Path) -> None:
(tmp_path / PACK_MANIFEST_FILENAME).write_text(json.dumps({"workflow_ids": "oops"}))
assert _read_pack_manifest(tmp_path) == []
class TestRemoveWorkflowsByIds:
"""Tests for _remove_workflows_by_ids()."""
def test_deletes_only_given_ids(self) -> None:
mock_service = MagicMock()
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies") as mock_deps:
mock_deps.invoker.services.workflow_records = mock_service
count = _remove_workflows_by_ids(["wf-1", "wf-2"], "test_pack")
assert count == 2
assert mock_service.delete.call_count == 2
deleted_ids = [
call.args[0] if call.args else call.kwargs.get("workflow_id") for call in mock_service.delete.call_args_list
]
assert deleted_ids == ["wf-1", "wf-2"]
def test_returns_zero_when_no_ids(self) -> None:
mock_service = MagicMock()
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies") as mock_deps:
mock_deps.invoker.services.workflow_records = mock_service
count = _remove_workflows_by_ids([], "empty_pack")
assert count == 0
mock_service.delete.assert_not_called()
def test_continues_on_individual_delete_error(self) -> None:
# One workflow is already gone; the helper still removes the others
mock_service = MagicMock()
mock_service.delete.side_effect = [Exception("not found"), None]
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies") as mock_deps:
mock_deps.invoker.services.workflow_records = mock_service
count = _remove_workflows_by_ids(["wf-gone", "wf-still-here"], "test_pack")
assert count == 1
def test_preserves_user_workflow_with_colliding_tag(self, tmp_path: Path) -> None:
# Regression test for the data-destruction risk the reviewer raised:
# If a user-authored workflow reuses the 'node-pack:<name>' tag, uninstall
# must NOT delete it. The full flow is exercised here: a manifest records
# only the pack's own workflow IDs, and _remove_workflows_by_ids operates
# only on those — so the user's workflow (whose id is NOT in the manifest)
# is never touched.
pack_wf_id = "pack-wf-1"
user_wf_id = "user-owned-wf-with-same-tag"
_write_pack_manifest(tmp_path, [pack_wf_id])
manifest_ids = _read_pack_manifest(tmp_path)
mock_service = MagicMock()
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies") as mock_deps:
mock_deps.invoker.services.workflow_records = mock_service
_remove_workflows_by_ids(manifest_ids, "test_pack")
assert mock_service.delete.call_count == 1
deleted_id = (
mock_service.delete.call_args.args[0]
if mock_service.delete.call_args.args
else mock_service.delete.call_args.kwargs.get("workflow_id")
)
assert deleted_id == pack_wf_id
# The user-owned workflow id is never passed to delete()
all_delete_args = [
(call.args[0] if call.args else call.kwargs.get("workflow_id"))
for call in mock_service.delete.call_args_list
]
assert user_wf_id not in all_delete_args
class TestUnregisterPack:
"""Tests for InvocationRegistry.unregister_pack()."""
def test_unregister_removes_invocations(self) -> None:
# Save original state
original_invocations = InvocationRegistry._invocation_classes.copy()
original_outputs = InvocationRegistry._output_classes.copy()
try:
# Create a mock invocation class
mock_inv = MagicMock(spec=BaseInvocation)
mock_inv.UIConfig = MagicMock()
mock_inv.UIConfig.node_pack = "test_removable_pack"
mock_inv.get_type.return_value = "test_removable_node"
InvocationRegistry._invocation_classes.add(mock_inv)
# Verify it's registered
assert mock_inv in InvocationRegistry._invocation_classes
# Unregister
removed = InvocationRegistry.unregister_pack("test_removable_pack")
assert "test_removable_node" in removed
assert mock_inv not in InvocationRegistry._invocation_classes
finally:
# Restore original state
InvocationRegistry._invocation_classes = original_invocations
InvocationRegistry._output_classes = original_outputs
def test_unregister_returns_empty_for_unknown_pack(self) -> None:
removed = InvocationRegistry.unregister_pack("nonexistent_pack_xyz")
assert removed == []
def test_unregister_removes_multiple_invocations(self) -> None:
original_invocations = InvocationRegistry._invocation_classes.copy()
original_outputs = InvocationRegistry._output_classes.copy()
try:
mock_inv_1 = MagicMock(spec=BaseInvocation)
mock_inv_1.UIConfig = MagicMock()
mock_inv_1.UIConfig.node_pack = "multi_pack"
mock_inv_1.get_type.return_value = "multi_node_1"
mock_inv_2 = MagicMock(spec=BaseInvocation)
mock_inv_2.UIConfig = MagicMock()
mock_inv_2.UIConfig.node_pack = "multi_pack"
mock_inv_2.get_type.return_value = "multi_node_2"
mock_inv_other = MagicMock(spec=BaseInvocation)
mock_inv_other.UIConfig = MagicMock()
mock_inv_other.UIConfig.node_pack = "other_pack"
mock_inv_other.get_type.return_value = "other_node"
InvocationRegistry._invocation_classes.update({mock_inv_1, mock_inv_2, mock_inv_other})
removed = InvocationRegistry.unregister_pack("multi_pack")
assert len(removed) == 2
assert "multi_node_1" in removed
assert "multi_node_2" in removed
# Other pack's node should remain
assert mock_inv_other in InvocationRegistry._invocation_classes
finally:
InvocationRegistry._invocation_classes = original_invocations
InvocationRegistry._output_classes = original_outputs
class TestPurgePackModules:
"""Tests for _purge_pack_modules() — clears the pack subtree from sys.modules."""
def test_removes_root_module(self) -> None:
sys.modules["purge_test_root"] = MagicMock()
try:
removed = _purge_pack_modules("purge_test_root")
assert "purge_test_root" in removed
assert "purge_test_root" not in sys.modules
finally:
sys.modules.pop("purge_test_root", None)
def test_removes_submodules(self) -> None:
sys.modules["purge_test_pack"] = MagicMock()
sys.modules["purge_test_pack.nodes"] = MagicMock()
sys.modules["purge_test_pack.utils.helpers"] = MagicMock()
try:
removed = _purge_pack_modules("purge_test_pack")
assert set(removed) == {
"purge_test_pack",
"purge_test_pack.nodes",
"purge_test_pack.utils.helpers",
}
assert "purge_test_pack" not in sys.modules
assert "purge_test_pack.nodes" not in sys.modules
assert "purge_test_pack.utils.helpers" not in sys.modules
finally:
for key in ("purge_test_pack", "purge_test_pack.nodes", "purge_test_pack.utils.helpers"):
sys.modules.pop(key, None)
def test_does_not_remove_unrelated_modules_with_prefix_collision(self) -> None:
# "foo_pack_extra" must NOT be removed when purging "foo_pack"
sys.modules["foo_pack"] = MagicMock()
sys.modules["foo_pack_extra"] = MagicMock()
sys.modules["foo_pack.sub"] = MagicMock()
try:
removed = _purge_pack_modules("foo_pack")
assert set(removed) == {"foo_pack", "foo_pack.sub"}
assert "foo_pack_extra" in sys.modules
finally:
for key in ("foo_pack", "foo_pack_extra", "foo_pack.sub"):
sys.modules.pop(key, None)
def test_noop_when_pack_not_loaded(self) -> None:
removed = _purge_pack_modules("never_loaded_pack_xyz")
assert removed == []
class TestUninstallReinstallReloadsSubmodules:
"""Regression test for the uninstall -> reinstall cache bug.
Before the fix, uninstall only cleared sys.modules[pack_name] and left
submodules cached. On reinstall, Python reused the cached submodules,
their @invocation decorators never re-ran, and the pack loaded with
zero registered nodes until a full process restart.
"""
def test_reinstall_re_executes_submodule(self, tmp_path: Path) -> None:
pack_name = "reinstall_regression_pack"
pack_dir = tmp_path / pack_name
pack_dir.mkdir()
# __init__.py imports from a submodule — this is the shape that triggered the bug
(pack_dir / "__init__.py").write_text("from .nodes import * # noqa: F401,F403\n")
submodule = pack_dir / "nodes.py"
# Each import of the submodule must append a marker to this file.
# If the submodule gets reused from sys.modules instead of re-executed,
# the second install won't produce a second marker.
marker_file = tmp_path / "exec_markers.txt"
submodule.write_text(
f"from pathlib import Path\nPath(r'{marker_file.as_posix()}').open('a').write('exec\\n')\n"
)
try:
# First install
_load_node_pack(pack_name, pack_dir)
assert pack_name in sys.modules
assert f"{pack_name}.nodes" in sys.modules
assert marker_file.read_text().count("exec") == 1
# Simulate uninstall's module cleanup
_purge_pack_modules(pack_name)
assert pack_name not in sys.modules
assert f"{pack_name}.nodes" not in sys.modules
# Reinstall — submodule MUST re-execute
_load_node_pack(pack_name, pack_dir)
assert marker_file.read_text().count("exec") == 2, (
"Submodule was not re-executed on reinstall — the @invocation "
"decorators would not have re-registered the pack's nodes."
)
finally:
_purge_pack_modules(pack_name)
@@ -0,0 +1,112 @@
"""Router-level tests for /api/v1/download_queue.
Covers:
- Auth gating (CurrentUserOrDefault on read/per-job, AdminUserOrDefault on prune & cancel-all).
- Bug regression: `dest` path validation must reject absolute paths and '..' segments
BEFORE the queue service is invoked.
"""
from typing import Any
from unittest.mock import MagicMock
import pytest
from fastapi import status
from fastapi.testclient import TestClient
from invokeai.app.services.download import DownloadJob
from invokeai.app.services.invoker import Invoker
def _make_job(id: int = 1) -> DownloadJob:
from pathlib import Path
return DownloadJob(id=id, source="http://example.com/file.bin", dest=Path("models/file.bin"))
# ----------------------------- Auth gating -----------------------------
@pytest.mark.parametrize(
("method", "path"),
[
("GET", "/api/v1/download_queue/"),
("PATCH", "/api/v1/download_queue/"),
("POST", "/api/v1/download_queue/i/"),
("GET", "/api/v1/download_queue/i/1"),
("DELETE", "/api/v1/download_queue/i/1"),
("DELETE", "/api/v1/download_queue/i"),
],
)
def test_routes_require_auth_in_multiuser_mode(enable_multiuser: Any, client: TestClient, method: str, path: str):
response = client.request(method, path, json={"source": "http://x/y", "dest": "models/x"})
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_list_downloads_as_regular_user(client: TestClient, user1_token: str, mock_invoker: Invoker):
mock_invoker.services.download_queue.list_jobs = MagicMock(return_value=[])
r = client.get("/api/v1/download_queue/", headers={"Authorization": f"Bearer {user1_token}"})
assert r.status_code == status.HTTP_200_OK
assert r.json() == []
def test_prune_downloads_forbidden_for_regular_user(client: TestClient, user1_token: str, mock_invoker: Invoker):
r = client.patch("/api/v1/download_queue/", headers={"Authorization": f"Bearer {user1_token}"})
assert r.status_code == status.HTTP_403_FORBIDDEN
mock_invoker.services.download_queue.prune_jobs.assert_not_called()
def test_prune_downloads_allowed_for_admin(client: TestClient, admin_token: str, mock_invoker: Invoker):
r = client.patch("/api/v1/download_queue/", headers={"Authorization": f"Bearer {admin_token}"})
assert r.status_code == status.HTTP_204_NO_CONTENT
mock_invoker.services.download_queue.prune_jobs.assert_called_once()
def test_cancel_all_forbidden_for_regular_user(client: TestClient, user1_token: str, mock_invoker: Invoker):
r = client.delete("/api/v1/download_queue/i", headers={"Authorization": f"Bearer {user1_token}"})
assert r.status_code == status.HTTP_403_FORBIDDEN
mock_invoker.services.download_queue.cancel_all_jobs.assert_not_called()
def test_cancel_all_allowed_for_admin(client: TestClient, admin_token: str, mock_invoker: Invoker):
r = client.delete("/api/v1/download_queue/i", headers={"Authorization": f"Bearer {admin_token}"})
assert r.status_code == status.HTTP_204_NO_CONTENT
mock_invoker.services.download_queue.cancel_all_jobs.assert_called_once()
# ----------------------------- Bug D regression: dest validation -----------------------------
@pytest.mark.parametrize(
"bad_dest",
[
"/etc/passwd",
"C:/Windows/System32",
"models/../../etc/passwd",
"..",
"",
" ",
],
)
def test_download_rejects_unsafe_dest_before_service_call(
client: TestClient, user1_token: str, mock_invoker: Invoker, bad_dest: str
):
"""Absolute paths, '..' segments, and empty strings must produce 400 and
must NOT invoke the download_queue service."""
r = client.post(
"/api/v1/download_queue/i/",
json={"source": "http://example.com/file.bin", "dest": bad_dest},
headers={"Authorization": f"Bearer {user1_token}"},
)
assert r.status_code == status.HTTP_400_BAD_REQUEST
mock_invoker.services.download_queue.download.assert_not_called()
def test_download_accepts_relative_dest(client: TestClient, user1_token: str, mock_invoker: Invoker):
mock_invoker.services.download_queue.download = MagicMock(return_value=_make_job())
r = client.post(
"/api/v1/download_queue/i/",
json={"source": "http://example.com/file.bin", "dest": "models/sd15.safetensors"},
headers={"Authorization": f"Bearer {user1_token}"},
)
assert r.status_code == status.HTTP_200_OK
mock_invoker.services.download_queue.download.assert_called_once()
+199
View File
@@ -0,0 +1,199 @@
import logging
from unittest.mock import MagicMock
import pytest
from fastapi import status
from fastapi.testclient import TestClient
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.api_app import app
from invokeai.app.services.auth.token_service import set_jwt_secret
from invokeai.app.services.board_image_records.board_image_records_sqlite import SqliteBoardImageRecordStorage
from invokeai.app.services.board_records.board_records_sqlite import SqliteBoardRecordStorage
from invokeai.app.services.boards.boards_default import BoardService
from invokeai.app.services.bulk_download.bulk_download_default import BulkDownloadService
from invokeai.app.services.client_state_persistence.client_state_persistence_sqlite import ClientStatePersistenceSqlite
from invokeai.app.services.config.config_default import InvokeAIAppConfig
from invokeai.app.services.image_moves.image_moves_default import ImageMoveJobAlreadyRunning, ImageMoveQueueActive
from invokeai.app.services.image_records.image_records_sqlite import SqliteImageRecordStorage
from invokeai.app.services.images.images_default import ImageService
from invokeai.app.services.invocation_cache.invocation_cache_memory import MemoryInvocationCache
from invokeai.app.services.invocation_services import InvocationServices
from invokeai.app.services.invocation_stats.invocation_stats_default import InvocationStatsService
from invokeai.app.services.invoker import Invoker
from invokeai.app.services.users.users_common import UserCreateRequest
from invokeai.app.services.users.users_default import UserService
from invokeai.app.services.workflow_records.workflow_records_sqlite import SqliteWorkflowRecordsStorage
from invokeai.backend.util.logging import InvokeAILogger
from tests.fixtures.sqlite_database import create_mock_sqlite_database
from tests.test_nodes import TestEventService
class MockApiDependencies(ApiDependencies):
invoker: Invoker
def __init__(self, invoker: Invoker) -> None:
self.invoker = invoker
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
@pytest.fixture
def mock_services() -> InvocationServices:
configuration = InvokeAIAppConfig(use_memory_db=True, node_cache_size=0)
logger = InvokeAILogger.get_logger()
db = create_mock_sqlite_database(configuration, logger)
image_moves = MagicMock()
return InvocationServices(
board_image_records=SqliteBoardImageRecordStorage(db=db),
board_images=None, # type: ignore
board_records=SqliteBoardRecordStorage(db=db),
boards=BoardService(),
bulk_download=BulkDownloadService(),
configuration=configuration,
events=TestEventService(),
image_files=None, # type: ignore
image_records=SqliteImageRecordStorage(db=db),
images=ImageService(),
invocation_cache=MemoryInvocationCache(max_cache_size=0),
logger=logging, # type: ignore
model_images=None, # type: ignore
model_manager=None, # type: ignore
download_queue=None, # type: ignore
external_generation=None, # type: ignore
names=None, # type: ignore
performance_statistics=InvocationStatsService(),
session_processor=None, # type: ignore
session_queue=None, # type: ignore
urls=None, # type: ignore
workflow_records=SqliteWorkflowRecordsStorage(db=db),
tensors=None, # type: ignore
conditioning=None, # type: ignore
style_preset_records=None, # type: ignore
style_preset_image_files=None, # type: ignore
workflow_thumbnails=None, # type: ignore
model_relationship_records=None, # type: ignore
model_relationships=None, # type: ignore
client_state_persistence=ClientStatePersistenceSqlite(db=db),
users=UserService(db),
image_moves=image_moves,
)
@pytest.fixture
def mock_invoker(mock_services: InvocationServices, monkeypatch: pytest.MonkeyPatch) -> Invoker:
invoker = Invoker(services=mock_services)
mock_deps = MockApiDependencies(invoker)
monkeypatch.setattr("invokeai.app.api.routers.image_moves.ApiDependencies", mock_deps)
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps)
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", mock_deps)
return invoker
def _status_payload(is_running: bool = True, operation: str = "move_all") -> dict:
return {
"is_running": is_running,
"operation": operation,
"active_job_id": None,
"latest_job": None,
"last_error": None,
"needs_move_count": 0,
}
def _create_user(invoker: Invoker, email: str, is_admin: bool) -> None:
invoker.services.users.create(
UserCreateRequest(
email=email,
display_name=email,
password="TestPass123",
is_admin=is_admin,
)
)
def _login(client: TestClient, email: str) -> str:
response = client.post("/api/v1/auth/login", json={"email": email, "password": "TestPass123"})
assert response.status_code == status.HTTP_200_OK
return response.json()["token"]
def test_start_image_move_returns_accepted_without_running_job_inline(
client: TestClient, mock_invoker: Invoker
) -> None:
image_moves = mock_invoker.services.image_moves
image_moves.start_background_move_all.return_value = _status_payload()
response = client.post("/api/v1/image_moves/start")
assert response.status_code == status.HTTP_202_ACCEPTED
assert response.json()["is_running"] is True
image_moves.start_background_move_all.assert_called_once_with()
image_moves.move_all_images.assert_not_called()
def test_start_image_move_rejects_overlapping_background_job(client: TestClient, mock_invoker: Invoker) -> None:
image_moves = mock_invoker.services.image_moves
image_moves.start_background_move_all.side_effect = ImageMoveJobAlreadyRunning("already running")
response = client.post("/api/v1/image_moves/start")
assert response.status_code == status.HTTP_409_CONFLICT
assert response.json()["detail"] == "already running"
def test_start_image_move_rejects_active_queue_work(client: TestClient, mock_invoker: Invoker) -> None:
image_moves = mock_invoker.services.image_moves
image_moves.start_background_move_all.side_effect = ImageMoveQueueActive("queue work is active")
response = client.post("/api/v1/image_moves/start")
assert response.status_code == status.HTTP_409_CONFLICT
assert response.json()["detail"] == "queue work is active"
def test_force_recovery_returns_accepted(client: TestClient, mock_invoker: Invoker) -> None:
image_moves = mock_invoker.services.image_moves
image_moves.start_background_recovery.return_value = _status_payload(operation="recovery")
response = client.post("/api/v1/image_moves/recover")
assert response.status_code == status.HTTP_202_ACCEPTED
assert response.json()["operation"] == "recovery"
image_moves.start_background_recovery.assert_called_once_with()
def test_image_move_status_uses_service_status(client: TestClient, mock_invoker: Invoker) -> None:
image_moves = mock_invoker.services.image_moves
image_moves.get_background_status.return_value = _status_payload(is_running=False)
response = client.get("/api/v1/image_moves/status")
assert response.status_code == status.HTTP_200_OK
assert response.json()["is_running"] is False
assert response.json()["needs_move_count"] == 0
image_moves.get_background_status.assert_called_once_with()
@pytest.mark.parametrize(
("method", "path"),
[
("post", "/api/v1/image_moves/start"),
("post", "/api/v1/image_moves/recover"),
("get", "/api/v1/image_moves/status"),
],
)
def test_image_move_endpoints_require_admin_in_multiuser_mode(
client: TestClient, mock_invoker: Invoker, method: str, path: str
) -> None:
set_jwt_secret("test-secret")
mock_invoker.services.configuration.multiuser = True
_create_user(mock_invoker, "user@test.com", is_admin=False)
token = _login(client, "user@test.com")
response = getattr(client, method)(path, headers={"Authorization": f"Bearer {token}"})
assert response.status_code == status.HTTP_403_FORBIDDEN
+222
View File
@@ -0,0 +1,222 @@
import os
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock
import pytest
from fastapi import BackgroundTasks
from fastapi.testclient import TestClient
from invokeai.app.api.auth_dependencies import get_current_user_or_default
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.api_app import app
from invokeai.app.services.auth.token_service import TokenData
from invokeai.app.services.board_records.board_records_common import BoardRecord
from invokeai.app.services.invoker import Invoker
@pytest.fixture(autouse=True, scope="module")
def client(invokeai_root_dir: Path) -> TestClient:
os.environ["INVOKEAI_ROOT"] = invokeai_root_dir.as_posix()
return TestClient(app)
class MockApiDependencies(ApiDependencies):
invoker: Invoker
def __init__(self, invoker) -> None:
self.invoker = invoker
def test_download_images_from_list(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None:
prepare_download_images_test(monkeypatch, mock_invoker)
response = client.post("/api/v1/images/download", json={"image_names": ["test.png"]})
json_response = response.json()
assert response.status_code == 202
assert json_response["bulk_download_item_name"] == "test.zip"
def test_download_images_from_board_id_empty_image_name_list(
monkeypatch: Any, mock_invoker: Invoker, client: TestClient
) -> None:
expected_board_name = "test"
def mock_get(*args, **kwargs):
return BoardRecord(board_id="12345", board_name=expected_board_name, created_at="None", updated_at="None")
monkeypatch.setattr(mock_invoker.services.board_records, "get", mock_get)
prepare_download_images_test(monkeypatch, mock_invoker)
response = client.post("/api/v1/images/download", json={"board_id": "test"})
json_response = response.json()
assert response.status_code == 202
assert json_response["bulk_download_item_name"] == "test.zip"
def prepare_download_images_test(monkeypatch: Any, mock_invoker: Invoker) -> None:
mock_deps = MockApiDependencies(mock_invoker)
monkeypatch.setattr("invokeai.app.api.routers.images.ApiDependencies", mock_deps)
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps)
monkeypatch.setattr(
"invokeai.app.api.routers.images.ApiDependencies.invoker.services.bulk_download.generate_item_id",
lambda arg: "test",
)
def mock_add_task(*args, **kwargs):
return None
monkeypatch.setattr(BackgroundTasks, "add_task", mock_add_task)
def prepare_image_maintenance_test(monkeypatch: Any, mock_invoker: Invoker) -> None:
mock_deps = MockApiDependencies(mock_invoker)
mock_invoker.services.image_moves = MagicMock()
mock_invoker.services.image_moves.is_maintenance_active.return_value = True
monkeypatch.setattr(mock_invoker.services.image_records, "get_user_id", MagicMock(return_value="system"))
monkeypatch.setattr(mock_invoker.services.board_image_records, "get_board_for_image", MagicMock(return_value=None))
monkeypatch.setattr("invokeai.app.api.routers.images.ApiDependencies", mock_deps)
monkeypatch.setattr("invokeai.app.api.routers._access.ApiDependencies", mock_deps)
monkeypatch.setattr("invokeai.app.api.routers.image_move_maintenance.ApiDependencies", mock_deps)
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps)
@pytest.mark.parametrize(
("method", "path", "json_body"),
[
("get", "/api/v1/images/i/test.png/full", None),
("head", "/api/v1/images/i/test.png/full", None),
("get", "/api/v1/images/i/test.png/thumbnail", None),
("get", "/api/v1/images/i/test.png/workflow", None),
("delete", "/api/v1/images/i/test.png", None),
("delete", "/api/v1/images/intermediates", None),
("delete", "/api/v1/images/uncategorized", None),
("patch", "/api/v1/images/i/test.png", {"starred": True}),
("post", "/api/v1/images/delete", {"image_names": ["test.png"]}),
("post", "/api/v1/images/star", {"image_names": ["test.png"]}),
("post", "/api/v1/images/unstar", {"image_names": ["test.png"]}),
("post", "/api/v1/images/download", {"image_names": ["test.png"]}),
],
)
def test_image_operations_are_blocked_during_image_move_maintenance(
monkeypatch: Any, mock_invoker: Invoker, client: TestClient, method: str, path: str, json_body: dict | None
) -> None:
prepare_image_maintenance_test(monkeypatch, mock_invoker)
if json_body is not None:
response = getattr(client, method)(path, json=json_body)
else:
response = getattr(client, method)(path)
assert response.status_code == 409
if method != "head":
assert response.json()["detail"] == "Image storage maintenance is active"
def test_image_mutation_checks_access_before_image_move_maintenance(
monkeypatch: Any, mock_invoker: Invoker, client: TestClient
) -> None:
prepare_image_maintenance_test(monkeypatch, mock_invoker)
monkeypatch.setattr(mock_invoker.services.image_records, "get_user_id", MagicMock(return_value="other-user"))
async def current_user_override() -> TokenData:
return TokenData(user_id="request-user", email="request-user@example.com", is_admin=False)
app.dependency_overrides[get_current_user_or_default] = current_user_override
try:
response = client.delete("/api/v1/images/i/test.png")
assert response.status_code == 403
mock_invoker.services.image_moves.is_maintenance_active.assert_not_called()
finally:
app.dependency_overrides.pop(get_current_user_or_default, None)
def test_image_upload_is_blocked_during_image_move_maintenance(
monkeypatch: Any, mock_invoker: Invoker, client: TestClient
) -> None:
prepare_image_maintenance_test(monkeypatch, mock_invoker)
response = client.post(
"/api/v1/images/upload",
params={"image_category": "general", "is_intermediate": False},
files={"file": ("test.png", b"not-read-during-maintenance", "image/png")},
)
assert response.status_code == 409
assert response.json()["detail"] == "Image storage maintenance is active"
def test_image_to_prompt_is_blocked_during_image_move_maintenance(
monkeypatch: Any, mock_invoker: Invoker, client: TestClient
) -> None:
prepare_image_maintenance_test(monkeypatch, mock_invoker)
response = client.post(
"/api/v1/utilities/image-to-prompt",
json={"image_name": "test.png", "model_key": "model-key", "instruction": "describe"},
)
assert response.status_code == 409
assert response.json()["detail"] == "Image storage maintenance is active"
def test_download_images_with_empty_image_list_and_no_board_id(
monkeypatch: Any, mock_invoker: Invoker, client: TestClient
) -> None:
prepare_download_images_test(monkeypatch, mock_invoker)
response = client.post("/api/v1/images/download", json={"image_names": []})
assert response.status_code == 400
def test_get_bulk_download_image(tmp_path: Path, monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None:
mock_file: Path = tmp_path / "test.zip"
mock_file.write_text("contents")
monkeypatch.setattr(mock_invoker.services.bulk_download, "get_path", lambda x: str(mock_file))
mock_deps = MockApiDependencies(mock_invoker)
monkeypatch.setattr("invokeai.app.api.routers.images.ApiDependencies", mock_deps)
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps)
def mock_add_task(*args, **kwargs):
return None
monkeypatch.setattr(BackgroundTasks, "add_task", mock_add_task)
response = client.get("/api/v1/images/download/test.zip")
assert response.status_code == 200
assert response.content == b"contents"
def test_get_bulk_download_image_not_found(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None:
mock_deps = MockApiDependencies(mock_invoker)
monkeypatch.setattr("invokeai.app.api.routers.images.ApiDependencies", mock_deps)
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps)
def mock_add_task(*args, **kwargs):
return None
monkeypatch.setattr(BackgroundTasks, "add_task", mock_add_task)
response = client.get("/api/v1/images/download/test.zip")
assert response.status_code == 404
def test_get_bulk_download_image_image_deleted_after_response(
monkeypatch: Any, mock_invoker: Invoker, tmp_path: Path, client: TestClient
) -> None:
mock_file: Path = tmp_path / "test.zip"
mock_file.write_text("contents")
monkeypatch.setattr(mock_invoker.services.bulk_download, "get_path", lambda x: str(mock_file))
mock_deps = MockApiDependencies(mock_invoker)
monkeypatch.setattr("invokeai.app.api.routers.images.ApiDependencies", mock_deps)
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps)
client.get("/api/v1/images/download/test.zip")
assert not (tmp_path / "test.zip").exists()
+185
View File
@@ -0,0 +1,185 @@
import os
from pathlib import Path
from typing import Any
import pytest
from fastapi.testclient import TestClient
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.api_app import app
from invokeai.backend.model_manager.configs.external_api import (
ExternalApiModelConfig,
ExternalModelCapabilities,
ExternalModelPanelSchema,
)
from invokeai.backend.model_manager.taxonomy import ModelType
@pytest.fixture(autouse=True, scope="module")
def client(invokeai_root_dir: Path) -> TestClient:
os.environ["INVOKEAI_ROOT"] = invokeai_root_dir.as_posix()
return TestClient(app)
class DummyModelImages:
def get_url(self, key: str) -> str:
return f"https://example.com/models/{key}.png"
class DummyInvoker:
def __init__(self, services: Any) -> None:
self.services = services
class MockApiDependencies(ApiDependencies):
invoker: DummyInvoker
def __init__(self, invoker: DummyInvoker) -> None:
self.invoker = invoker
def test_model_manager_external_config_round_trip(
monkeypatch: Any, client: TestClient, mm2_model_manager: Any, mm2_app_config: Any
) -> None:
config = ExternalApiModelConfig(
key="external_test",
name="External Test",
provider_id="openai",
provider_model_id="gpt-image-1",
capabilities=ExternalModelCapabilities(modes=["txt2img"]),
)
mm2_model_manager.store.add_model(config)
services = type("Services", (), {})()
services.model_manager = mm2_model_manager
services.model_images = DummyModelImages()
services.configuration = mm2_app_config
invoker = DummyInvoker(services)
monkeypatch.setattr("invokeai.app.api.routers.model_manager.ApiDependencies", MockApiDependencies(invoker))
response = client.get("/api/v2/models/", params={"model_type": ModelType.ExternalImageGenerator.value})
assert response.status_code == 200
payload = response.json()
assert len(payload["models"]) == 1
assert payload["models"][0]["key"] == "external_test"
assert payload["models"][0]["provider_id"] == "openai"
assert payload["models"][0]["cover_image"] == "https://example.com/models/external_test.png"
get_response = client.get("/api/v2/models/i/external_test")
assert get_response.status_code == 200
model_payload = get_response.json()
assert model_payload["provider_model_id"] == "gpt-image-1"
assert model_payload["cover_image"] == "https://example.com/models/external_test.png"
def test_model_manager_external_config_preserves_custom_panel_schema(
monkeypatch: Any, client: TestClient, mm2_model_manager: Any, mm2_app_config: Any
) -> None:
config = ExternalApiModelConfig(
key="external_custom_schema",
name="External Custom Schema",
provider_id="custom",
provider_model_id="custom-model",
capabilities=ExternalModelCapabilities(modes=["txt2img"]),
panel_schema=ExternalModelPanelSchema(
prompts=[{"name": "reference_images"}],
image=[{"name": "dimensions"}],
),
source="external://custom/custom-model",
)
mm2_model_manager.store.add_model(config)
services = type("Services", (), {})()
services.model_manager = mm2_model_manager
services.model_images = DummyModelImages()
services.configuration = mm2_app_config
invoker = DummyInvoker(services)
monkeypatch.setattr("invokeai.app.api.routers.model_manager.ApiDependencies", MockApiDependencies(invoker))
response = client.get("/api/v2/models/i/external_custom_schema")
assert response.status_code == 200
payload = response.json()
assert [control["name"] for control in payload["panel_schema"]["prompts"]] == ["reference_images"]
assert [control["name"] for control in payload["panel_schema"]["image"]] == ["dimensions"]
def test_model_manager_external_starter_model_applies_panel_schema_overrides(
monkeypatch: Any, client: TestClient, mm2_model_manager: Any, mm2_app_config: Any
) -> None:
config = ExternalApiModelConfig(
key="external_starter_schema",
name="Starter Schema Test",
provider_id="openai",
provider_model_id="gpt-image-1",
capabilities=ExternalModelCapabilities(
modes=["txt2img"],
supports_reference_images=False,
),
)
mm2_model_manager.store.add_model(config)
services = type("Services", (), {})()
services.model_manager = mm2_model_manager
services.model_images = DummyModelImages()
services.configuration = mm2_app_config
invoker = DummyInvoker(services)
monkeypatch.setattr("invokeai.app.api.routers.model_manager.ApiDependencies", MockApiDependencies(invoker))
response = client.get("/api/v2/models/i/external_starter_schema")
assert response.status_code == 200
payload = response.json()
assert [control["name"] for control in payload["panel_schema"]["prompts"]] == ["reference_images"]
assert [control["name"] for control in payload["panel_schema"]["image"]] == ["dimensions"]
assert payload["panel_schema"]["generation"] == []
def test_model_manager_gemini_starter_model_applies_reference_and_resolution_overrides(
monkeypatch: Any, client: TestClient, mm2_model_manager: Any, mm2_app_config: Any
) -> None:
config = ExternalApiModelConfig(
key="external_gemini_schema",
name="Gemini Starter Schema Test",
provider_id="gemini",
provider_model_id="gemini-3.1-flash-image-preview",
capabilities=ExternalModelCapabilities(modes=["txt2img"]),
source="external://gemini/gemini-3.1-flash-image-preview",
)
mm2_model_manager.store.add_model(config)
services = type("Services", (), {})()
services.model_manager = mm2_model_manager
services.model_images = DummyModelImages()
services.configuration = mm2_app_config
invoker = DummyInvoker(services)
monkeypatch.setattr("invokeai.app.api.routers.model_manager.ApiDependencies", MockApiDependencies(invoker))
response = client.get("/api/v2/models/i/external_gemini_schema")
assert response.status_code == 200
payload = response.json()
assert payload["capabilities"]["max_reference_images"] == 14
assert payload["capabilities"]["max_image_size"] == {"width": 4096, "height": 4096}
assert payload["capabilities"]["allowed_aspect_ratios"] == [
"1:1",
"1:4",
"1:8",
"2:3",
"3:2",
"3:4",
"4:1",
"4:3",
"4:5",
"5:4",
"8:1",
"9:16",
"16:9",
"21:9",
]
@@ -0,0 +1,156 @@
"""Router-level tests for /api/v1/model_relationships.
Covers:
- Auth gating (CurrentUserOrDefault on read/batch, AdminUserOrDefault on add/remove).
- Bug regression: self-relationship checks must return 400 (not 500 — the previous
broad `except Exception` swallowed the HTTPException and converted it).
- Service exception mapping: ValueError → 409 on add, 404 on remove.
"""
from typing import Any
from unittest.mock import MagicMock
import pytest
from fastapi import status
from fastapi.testclient import TestClient
from invokeai.app.services.invoker import Invoker
REQ_BODY = {
"model_key_1": "aa3b247f-90c9-4416-bfcd-aeaa57a5339e",
"model_key_2": "ac32b914-10ab-496e-a24a-3068724b9c35",
}
# ----------------------------- Auth gating -----------------------------
@pytest.mark.parametrize(
("method", "path", "body"),
[
("GET", "/api/v1/model_relationships/i/some-key", None),
("POST", "/api/v1/model_relationships/", REQ_BODY),
("DELETE", "/api/v1/model_relationships/", REQ_BODY),
("POST", "/api/v1/model_relationships/batch", {"model_keys": ["a", "b"]}),
],
)
def test_routes_require_auth(enable_multiuser: Any, client: TestClient, method: str, path: str, body: dict | None):
r = client.request(method, path, json=body)
assert r.status_code == status.HTTP_401_UNAUTHORIZED
def test_get_related_models_allowed_for_regular_user(client: TestClient, user1_token: str, mock_invoker: Invoker):
mock_invoker.services.model_relationships.get_related_model_keys = MagicMock(return_value=["k1"])
r = client.get(
"/api/v1/model_relationships/i/some-key",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert r.status_code == status.HTTP_200_OK
assert r.json() == ["k1"]
def test_batch_allowed_for_regular_user(client: TestClient, user1_token: str, mock_invoker: Invoker):
mock_invoker.services.model_relationships.get_related_model_keys = MagicMock(side_effect=lambda k: [f"r-{k}"])
r = client.post(
"/api/v1/model_relationships/batch",
json={"model_keys": ["a", "b"]},
headers={"Authorization": f"Bearer {user1_token}"},
)
assert r.status_code == status.HTTP_200_OK
assert set(r.json()) == {"r-a", "r-b"}
def test_add_forbidden_for_regular_user(client: TestClient, user1_token: str, mock_invoker: Invoker):
r = client.post(
"/api/v1/model_relationships/",
json=REQ_BODY,
headers={"Authorization": f"Bearer {user1_token}"},
)
assert r.status_code == status.HTTP_403_FORBIDDEN
mock_invoker.services.model_relationships.add_model_relationship.assert_not_called()
def test_remove_forbidden_for_regular_user(client: TestClient, user1_token: str, mock_invoker: Invoker):
r = client.request(
"DELETE",
"/api/v1/model_relationships/",
json=REQ_BODY,
headers={"Authorization": f"Bearer {user1_token}"},
)
assert r.status_code == status.HTTP_403_FORBIDDEN
mock_invoker.services.model_relationships.remove_model_relationship.assert_not_called()
def test_add_allowed_for_admin(client: TestClient, admin_token: str, mock_invoker: Invoker):
r = client.post(
"/api/v1/model_relationships/",
json=REQ_BODY,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert r.status_code == status.HTTP_204_NO_CONTENT
mock_invoker.services.model_relationships.add_model_relationship.assert_called_once()
def test_remove_allowed_for_admin(client: TestClient, admin_token: str, mock_invoker: Invoker):
r = client.request(
"DELETE",
"/api/v1/model_relationships/",
json=REQ_BODY,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert r.status_code == status.HTTP_204_NO_CONTENT
mock_invoker.services.model_relationships.remove_model_relationship.assert_called_once()
# ----------------------------- Bug A regression: self-relationship → 400 -----------------------------
def test_add_self_relationship_returns_400_not_500(client: TestClient, admin_token: str, mock_invoker: Invoker):
"""Before the fix, the inner HTTPException(400) was caught by `except Exception`
and re-raised as 500."""
r = client.post(
"/api/v1/model_relationships/",
json={"model_key_1": "same-key", "model_key_2": "same-key"},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert r.status_code == status.HTTP_400_BAD_REQUEST
mock_invoker.services.model_relationships.add_model_relationship.assert_not_called()
def test_remove_self_relationship_returns_400_not_500(client: TestClient, admin_token: str, mock_invoker: Invoker):
r = client.request(
"DELETE",
"/api/v1/model_relationships/",
json={"model_key_1": "same-key", "model_key_2": "same-key"},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert r.status_code == status.HTTP_400_BAD_REQUEST
mock_invoker.services.model_relationships.remove_model_relationship.assert_not_called()
# ----------------------------- Service exception mapping -----------------------------
def test_add_value_error_returns_409(client: TestClient, admin_token: str, mock_invoker: Invoker):
mock_invoker.services.model_relationships.add_model_relationship = MagicMock(
side_effect=ValueError("relationship already exists")
)
r = client.post(
"/api/v1/model_relationships/",
json=REQ_BODY,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert r.status_code == status.HTTP_409_CONFLICT
def test_remove_value_error_returns_404(client: TestClient, admin_token: str, mock_invoker: Invoker):
mock_invoker.services.model_relationships.remove_model_relationship = MagicMock(
side_effect=ValueError("relationship not found")
)
r = client.request(
"DELETE",
"/api/v1/model_relationships/",
json=REQ_BODY,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert r.status_code == status.HTTP_404_NOT_FOUND
File diff suppressed because it is too large Load Diff
+834
View File
@@ -0,0 +1,834 @@
"""Tests for the recall parameters router.
These tests monkey-patch the heavy-weight lookup helpers
(``resolve_model_name_to_key``, ``load_image_file``,
``process_controlnet_image``) rather than wiring up a real model manager
or image-files service. This keeps each test focused on the router's
request-validation, resolver sequencing, and broadcast payload shape.
"""
from collections.abc import Callable
from typing import Any, Optional
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from PIL import Image
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.api.routers import recall_parameters as recall_module
from invokeai.app.api.routers.recall_parameters import load_image_file
from invokeai.app.api_app import app
from invokeai.app.services.invoker import Invoker
from invokeai.backend.model_manager.taxonomy import ModelType
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
class MockApiDependencies(ApiDependencies):
"""Minimal ApiDependencies stand-in that only wires up an invoker."""
invoker: Invoker
def __init__(self, invoker: Invoker) -> None:
self.invoker = invoker
@pytest.fixture
def patched_dependencies(monkeypatch: Any, mock_invoker: Invoker) -> MockApiDependencies:
"""Install a mock ApiDependencies for the recall_parameters router.
The router persists each parameter via ``client_state_persistence.set_by_key``,
whose ``user_id`` column has a FOREIGN KEY constraint back to the users
table. The mock invoker uses an in-memory SQLite database that is not
pre-populated with any users, so persistence would fail with "FOREIGN
KEY constraint failed" — that's an orthogonal concern to the reference-
images resolver under test, so we stub it out.
"""
dependencies = MockApiDependencies(mock_invoker)
monkeypatch.setattr("invokeai.app.api.routers.recall_parameters.ApiDependencies", dependencies)
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", dependencies)
monkeypatch.setattr(
mock_invoker.services.client_state_persistence,
"set_by_key",
lambda user_id, key, value: value,
)
return dependencies
def make_name_to_key_stub(
mapping: dict[tuple[str, ModelType], str],
) -> Callable[[str, ModelType], Optional[str]]:
"""Build a ``resolve_model_name_to_key`` stand-in from a (name, type) dict.
Any lookup that is not present in ``mapping`` returns ``None``, mirroring
what the real resolver does when the model manager cannot find a match.
"""
def _lookup(model_name: str, model_type: ModelType = ModelType.Main) -> Optional[str]:
return mapping.get((model_name, model_type))
return _lookup
def make_load_image_file_stub(
known_images: dict[str, tuple[int, int]],
) -> Callable[[str], Optional[dict[str, Any]]]:
"""Build a ``load_image_file`` stand-in from a name → (width, height) dict."""
def _load(image_name: str) -> Optional[dict[str, Any]]:
dims = known_images.get(image_name)
if dims is None:
return None
width, height = dims
return {"image_name": image_name, "width": width, "height": height}
return _load
def test_recall_parameters_is_blocked_during_image_move_maintenance(
monkeypatch: Any, patched_dependencies: MockApiDependencies, mock_invoker: Invoker, client: TestClient
) -> None:
mock_invoker.services.image_moves = MagicMock()
mock_invoker.services.image_moves.is_maintenance_active.return_value = True
monkeypatch.setattr("invokeai.app.api.routers.image_move_maintenance.ApiDependencies", patched_dependencies)
response = client.post("/api/v1/recall/default", json={"positive_prompt": "hello"})
assert response.status_code == 409
assert response.json()["detail"] == "Image storage maintenance is active"
class TestReferenceImagesRecall:
def test_reference_images_forwarded_when_image_exists(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""Reference images whose files exist should flow through to the event payload."""
# Stub load_image_file so we don't need a real outputs/images directory.
def fake_load_image_file(image_name: str) -> dict[str, Any] | None:
return {"image_name": image_name, "width": 1024, "height": 768}
monkeypatch.setattr(recall_module, "load_image_file", fake_load_image_file)
response = client.post(
"/api/v1/recall/default",
json={
"reference_images": [
{"image_name": "cat.png"},
{"image_name": "dog.png"},
]
},
)
assert response.status_code == 200
body = response.json()
assert body["status"] == "success"
assert body["queue_id"] == "default"
# Both references came through, in order.
resolved = body["parameters"]["reference_images"]
assert len(resolved) == 2
assert resolved[0]["image"]["image_name"] == "cat.png"
assert resolved[1]["image"]["image_name"] == "dog.png"
assert resolved[0]["image"]["width"] == 1024
def test_missing_reference_images_are_dropped_without_failing(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""An image that can't be loaded should be skipped — never 500."""
def fake_load_image_file(image_name: str) -> dict[str, Any] | None:
if image_name == "present.png":
return {"image_name": image_name, "width": 512, "height": 512}
return None
monkeypatch.setattr(recall_module, "load_image_file", fake_load_image_file)
response = client.post(
"/api/v1/recall/default",
json={
"reference_images": [
{"image_name": "missing.png"},
{"image_name": "present.png"},
]
},
)
assert response.status_code == 200
resolved = response.json()["parameters"]["reference_images"]
assert len(resolved) == 1
assert resolved[0]["image"]["image_name"] == "present.png"
def test_reference_images_do_not_require_model_name(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""The schema must accept a reference image entry with only ``image_name``.
This pins down the "model-free" contract: unlike ``ip_adapters``,
these entries are for FLUX.2 Klein / FLUX Kontext / Qwen Image Edit,
where the reference image feeds the main model directly and there is
no adapter model to name. Callers should be able to omit every
field except ``image_name``.
"""
monkeypatch.setattr(
recall_module,
"load_image_file",
lambda image_name: {"image_name": image_name, "width": 64, "height": 64},
)
response = client.post(
"/api/v1/recall/default",
json={"reference_images": [{"image_name": "ok.png"}]},
)
assert response.status_code == 200
resolved = response.json()["parameters"]["reference_images"]
assert resolved == [{"image": {"image_name": "ok.png", "width": 64, "height": 64}}]
def test_empty_reference_images_is_noop_for_other_fields(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""Sending an empty reference_images list should not break other fields."""
monkeypatch.setattr(
recall_module,
"load_image_file",
lambda image_name: {"image_name": image_name, "width": 1, "height": 1},
)
response = client.post(
"/api/v1/recall/default",
json={
"positive_prompt": "hello",
"reference_images": [],
},
)
assert response.status_code == 200
params = response.json()["parameters"]
assert params["positive_prompt"] == "hello"
assert params["reference_images"] == []
class TestLorasRecall:
def test_multiple_loras_resolved_with_weights_and_is_enabled(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""Each LoRA's model name is resolved to a key and weight/is_enabled pass through."""
monkeypatch.setattr(
recall_module,
"resolve_model_name_to_key",
make_name_to_key_stub(
{
("detail-lora", ModelType.LoRA): "key-detail",
("style-lora", ModelType.LoRA): "key-style",
}
),
)
response = client.post(
"/api/v1/recall/default",
json={
"loras": [
{"model_name": "detail-lora", "weight": 0.8, "is_enabled": True},
{"model_name": "style-lora", "weight": 0.5, "is_enabled": False},
]
},
)
assert response.status_code == 200
loras = response.json()["parameters"]["loras"]
assert loras == [
{"model_key": "key-detail", "weight": 0.8, "is_enabled": True},
{"model_key": "key-style", "weight": 0.5, "is_enabled": False},
]
def test_unresolvable_loras_are_dropped(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""LoRAs whose names do not resolve are silently skipped — not an error."""
monkeypatch.setattr(
recall_module,
"resolve_model_name_to_key",
make_name_to_key_stub({("keeper", ModelType.LoRA): "key-keeper"}),
)
response = client.post(
"/api/v1/recall/default",
json={
"loras": [
{"model_name": "keeper", "weight": 0.7},
{"model_name": "ghost-lora"},
]
},
)
assert response.status_code == 200
loras = response.json()["parameters"]["loras"]
assert len(loras) == 1
assert loras[0]["model_key"] == "key-keeper"
def test_is_enabled_defaults_to_true(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""Omitting is_enabled should default to True per the pydantic schema."""
monkeypatch.setattr(
recall_module,
"resolve_model_name_to_key",
make_name_to_key_stub({("x", ModelType.LoRA): "key-x"}),
)
response = client.post(
"/api/v1/recall/default",
json={"loras": [{"model_name": "x"}]},
)
assert response.status_code == 200
assert response.json()["parameters"]["loras"][0]["is_enabled"] is True
class TestControlLayersRecall:
def test_controlnet_resolution_takes_precedence(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""A name that matches a ControlNet model should resolve to it directly."""
monkeypatch.setattr(
recall_module,
"resolve_model_name_to_key",
make_name_to_key_stub({("canny", ModelType.ControlNet): "key-canny"}),
)
monkeypatch.setattr(
recall_module,
"load_image_file",
make_load_image_file_stub({"ctl.png": (512, 512)}),
)
monkeypatch.setattr(recall_module, "process_controlnet_image", lambda *a, **kw: None)
response = client.post(
"/api/v1/recall/default",
json={
"control_layers": [
{
"model_name": "canny",
"image_name": "ctl.png",
"weight": 0.75,
"begin_step_percent": 0.1,
"end_step_percent": 0.9,
"control_mode": "balanced",
}
]
},
)
assert response.status_code == 200
layer = response.json()["parameters"]["control_layers"][0]
assert layer["model_key"] == "key-canny"
assert layer["weight"] == 0.75
assert layer["begin_step_percent"] == 0.1
assert layer["end_step_percent"] == 0.9
assert layer["control_mode"] == "balanced"
assert layer["image"] == {"image_name": "ctl.png", "width": 512, "height": 512}
# processor returned None → no processed_image field
assert "processed_image" not in layer
def test_falls_back_to_t2i_adapter(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""When no ControlNet match exists, T2I Adapter is tried next."""
monkeypatch.setattr(
recall_module,
"resolve_model_name_to_key",
make_name_to_key_stub({("sketchy", ModelType.T2IAdapter): "key-t2i"}),
)
monkeypatch.setattr(recall_module, "load_image_file", make_load_image_file_stub({}))
monkeypatch.setattr(recall_module, "process_controlnet_image", lambda *a, **kw: None)
response = client.post(
"/api/v1/recall/default",
json={"control_layers": [{"model_name": "sketchy", "weight": 1.0}]},
)
assert response.status_code == 200
assert response.json()["parameters"]["control_layers"][0]["model_key"] == "key-t2i"
def test_falls_back_to_control_lora(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""When neither ControlNet nor T2I Adapter matches, Control LoRA is tried last."""
monkeypatch.setattr(
recall_module,
"resolve_model_name_to_key",
make_name_to_key_stub({("clora", ModelType.LoRA): "key-clora"}),
)
monkeypatch.setattr(recall_module, "load_image_file", make_load_image_file_stub({}))
monkeypatch.setattr(recall_module, "process_controlnet_image", lambda *a, **kw: None)
response = client.post(
"/api/v1/recall/default",
json={"control_layers": [{"model_name": "clora", "weight": 1.0}]},
)
assert response.status_code == 200
assert response.json()["parameters"]["control_layers"][0]["model_key"] == "key-clora"
def test_missing_image_still_resolves_config(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""A missing control image is warned about but does not block the rest of the config."""
monkeypatch.setattr(
recall_module,
"resolve_model_name_to_key",
make_name_to_key_stub({("canny", ModelType.ControlNet): "key-canny"}),
)
monkeypatch.setattr(recall_module, "load_image_file", make_load_image_file_stub({}))
monkeypatch.setattr(recall_module, "process_controlnet_image", lambda *a, **kw: None)
response = client.post(
"/api/v1/recall/default",
json={
"control_layers": [
{
"model_name": "canny",
"image_name": "missing.png",
"weight": 0.75,
}
]
},
)
assert response.status_code == 200
layer = response.json()["parameters"]["control_layers"][0]
assert layer["model_key"] == "key-canny"
assert layer["weight"] == 0.75
assert "image" not in layer
assert "processed_image" not in layer
def test_processed_image_included_when_processor_returns_data(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""When the processor produces a derived image, it is attached to the resolved layer."""
monkeypatch.setattr(
recall_module,
"resolve_model_name_to_key",
make_name_to_key_stub({("canny", ModelType.ControlNet): "key-canny"}),
)
monkeypatch.setattr(
recall_module,
"load_image_file",
make_load_image_file_stub({"ctl.png": (768, 768)}),
)
monkeypatch.setattr(
recall_module,
"process_controlnet_image",
lambda image_name, model_key, services: {
"image_name": f"processed-{image_name}",
"width": 768,
"height": 768,
},
)
response = client.post(
"/api/v1/recall/default",
json={"control_layers": [{"model_name": "canny", "image_name": "ctl.png", "weight": 1.0}]},
)
assert response.status_code == 200
layer = response.json()["parameters"]["control_layers"][0]
assert layer["processed_image"]["image_name"] == "processed-ctl.png"
def test_unresolvable_control_layers_are_dropped(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""Control entries whose model doesn't resolve by any type are skipped."""
monkeypatch.setattr(
recall_module,
"resolve_model_name_to_key",
make_name_to_key_stub({}),
)
monkeypatch.setattr(recall_module, "load_image_file", make_load_image_file_stub({}))
monkeypatch.setattr(recall_module, "process_controlnet_image", lambda *a, **kw: None)
response = client.post(
"/api/v1/recall/default",
json={"control_layers": [{"model_name": "unknown", "weight": 1.0}]},
)
assert response.status_code == 200
assert response.json()["parameters"]["control_layers"] == []
class TestIPAdaptersRecall:
def test_ip_adapter_resolved_with_image_and_method(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""IPAdapter lookup is tried first and all config fields pass through."""
monkeypatch.setattr(
recall_module,
"resolve_model_name_to_key",
make_name_to_key_stub({("ipa-face", ModelType.IPAdapter): "key-ipa"}),
)
monkeypatch.setattr(
recall_module,
"load_image_file",
make_load_image_file_stub({"ref.png": (1024, 1024)}),
)
response = client.post(
"/api/v1/recall/default",
json={
"ip_adapters": [
{
"model_name": "ipa-face",
"image_name": "ref.png",
"weight": 0.7,
"begin_step_percent": 0.0,
"end_step_percent": 0.8,
"method": "style",
}
]
},
)
assert response.status_code == 200
adapter = response.json()["parameters"]["ip_adapters"][0]
assert adapter["model_key"] == "key-ipa"
assert adapter["weight"] == 0.7
assert adapter["begin_step_percent"] == 0.0
assert adapter["end_step_percent"] == 0.8
assert adapter["method"] == "style"
assert adapter["image"] == {"image_name": "ref.png", "width": 1024, "height": 1024}
# image_influence was not sent, so it must not appear in the resolved config
assert "image_influence" not in adapter
def test_falls_back_to_flux_redux(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""When the name doesn't match an IPAdapter, FluxRedux is tried next."""
monkeypatch.setattr(
recall_module,
"resolve_model_name_to_key",
make_name_to_key_stub({("redux-1", ModelType.FluxRedux): "key-redux"}),
)
monkeypatch.setattr(
recall_module,
"load_image_file",
make_load_image_file_stub({"ref.png": (512, 512)}),
)
response = client.post(
"/api/v1/recall/default",
json={
"ip_adapters": [
{
"model_name": "redux-1",
"image_name": "ref.png",
"weight": 1.0,
"image_influence": "high",
}
]
},
)
assert response.status_code == 200
adapter = response.json()["parameters"]["ip_adapters"][0]
assert adapter["model_key"] == "key-redux"
assert adapter["image_influence"] == "high"
def test_missing_image_still_resolves_config(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""A missing reference image is warned about but the adapter still lands."""
monkeypatch.setattr(
recall_module,
"resolve_model_name_to_key",
make_name_to_key_stub({("ipa", ModelType.IPAdapter): "key-ipa"}),
)
monkeypatch.setattr(recall_module, "load_image_file", make_load_image_file_stub({}))
response = client.post(
"/api/v1/recall/default",
json={"ip_adapters": [{"model_name": "ipa", "image_name": "missing.png", "weight": 0.5}]},
)
assert response.status_code == 200
adapter = response.json()["parameters"]["ip_adapters"][0]
assert adapter["model_key"] == "key-ipa"
assert adapter["weight"] == 0.5
assert "image" not in adapter
def test_unresolvable_ip_adapters_are_dropped(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""Adapters whose model can't be resolved (neither IPAdapter nor FluxRedux) are skipped."""
monkeypatch.setattr(
recall_module,
"resolve_model_name_to_key",
make_name_to_key_stub({}),
)
monkeypatch.setattr(recall_module, "load_image_file", make_load_image_file_stub({}))
response = client.post(
"/api/v1/recall/default",
json={"ip_adapters": [{"model_name": "unknown", "weight": 1.0}]},
)
assert response.status_code == 200
assert response.json()["parameters"]["ip_adapters"] == []
class TestCombinedRecall:
def test_all_collection_fields_together(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""Exercise the full happy path: prompts, model, loras, control_layers, ip_adapters, reference_images."""
monkeypatch.setattr(
recall_module,
"resolve_model_name_to_key",
make_name_to_key_stub(
{
("my-model", ModelType.Main): "key-main",
("detail-lora", ModelType.LoRA): "key-lora",
("canny", ModelType.ControlNet): "key-canny",
("ipa-face", ModelType.IPAdapter): "key-ipa",
}
),
)
monkeypatch.setattr(
recall_module,
"load_image_file",
make_load_image_file_stub(
{
"ctl.png": (512, 512),
"face.png": (768, 768),
"ref.png": (1024, 1024),
}
),
)
monkeypatch.setattr(recall_module, "process_controlnet_image", lambda *a, **kw: None)
response = client.post(
"/api/v1/recall/default",
json={
"positive_prompt": "a cat",
"negative_prompt": "blurry",
"model": "my-model",
"steps": 30,
"cfg_scale": 7.5,
"width": 512,
"height": 512,
"seed": 42,
"loras": [{"model_name": "detail-lora", "weight": 0.6}],
"control_layers": [{"model_name": "canny", "image_name": "ctl.png", "weight": 0.75}],
"ip_adapters": [
{"model_name": "ipa-face", "image_name": "face.png", "weight": 0.5, "method": "composition"}
],
"reference_images": [{"image_name": "ref.png"}],
},
)
assert response.status_code == 200
params = response.json()["parameters"]
# Core fields
assert params["positive_prompt"] == "a cat"
assert params["negative_prompt"] == "blurry"
assert params["model"] == "key-main"
assert params["steps"] == 30
assert params["seed"] == 42
# Collections
assert params["loras"] == [{"model_key": "key-lora", "weight": 0.6, "is_enabled": True}]
assert params["control_layers"][0]["model_key"] == "key-canny"
assert params["control_layers"][0]["image"]["image_name"] == "ctl.png"
assert params["ip_adapters"][0]["model_key"] == "key-ipa"
assert params["ip_adapters"][0]["method"] == "composition"
assert params["reference_images"] == [{"image": {"image_name": "ref.png", "width": 1024, "height": 1024}}]
def test_unresolvable_main_model_drops_from_payload(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""A model name that doesn't resolve should be scrubbed from the broadcast payload."""
monkeypatch.setattr(
recall_module,
"resolve_model_name_to_key",
make_name_to_key_stub({}),
)
response = client.post(
"/api/v1/recall/default",
json={"positive_prompt": "x", "model": "ghost-model"},
)
assert response.status_code == 200
params = response.json()["parameters"]
assert params["positive_prompt"] == "x"
assert "model" not in params
class TestStrictMode:
"""Regression tests for the ``strict`` query parameter.
When ``strict=True``, parameters not included in the request body must
be reset — list-typed fields to ``[]`` and scalar fields to ``None``.
"""
def test_strict_clears_list_fields(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""List fields (loras, control_layers, ip_adapters, reference_images) are
sent as empty lists when omitted in strict mode."""
monkeypatch.setattr(recall_module, "resolve_model_name_to_key", make_name_to_key_stub({}))
response = client.post(
"/api/v1/recall/default?strict=true",
json={"positive_prompt": "hello"},
)
assert response.status_code == 200
params = response.json()["parameters"]
assert params["positive_prompt"] == "hello"
assert params["loras"] == []
assert params["control_layers"] == []
assert params["ip_adapters"] == []
assert params["reference_images"] == []
def test_strict_clears_scalar_fields(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""Scalar fields not in the request are sent as None in strict mode."""
monkeypatch.setattr(recall_module, "resolve_model_name_to_key", make_name_to_key_stub({}))
response = client.post(
"/api/v1/recall/default?strict=true",
json={"steps": 20},
)
assert response.status_code == 200
params = response.json()["parameters"]
assert params["steps"] == 20
assert params["positive_prompt"] is None
assert params["seed"] is None
assert params["loras"] == []
def test_non_strict_omits_unset_fields(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
"""Default (non-strict) behaviour: unset fields are absent from the response."""
monkeypatch.setattr(recall_module, "resolve_model_name_to_key", make_name_to_key_stub({}))
response = client.post(
"/api/v1/recall/default",
json={"positive_prompt": "hello"},
)
assert response.status_code == 200
params = response.json()["parameters"]
assert params["positive_prompt"] == "hello"
assert "loras" not in params
assert "reference_images" not in params
assert "seed" not in params
class TestAppendMode:
"""Tests for the ``append`` query parameter.
``append=true`` asks the frontend to add the recalled reference images to
its existing list instead of replacing it. The flag travels inside the
event's ``parameters`` dict (so the generated client schema needs no
change) and must never be persisted as a recall parameter.
"""
def test_append_flag_rides_in_parameters(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
monkeypatch.setattr(recall_module, "load_image_file", make_load_image_file_stub({"cat.png": (1024, 768)}))
response = client.post(
"/api/v1/recall/default?append=true",
json={"reference_images": [{"image_name": "cat.png"}]},
)
assert response.status_code == 200
params = response.json()["parameters"]
assert params["append"] is True
assert params["reference_images"][0]["image"]["image_name"] == "cat.png"
def test_append_flag_absent_by_default(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
monkeypatch.setattr(recall_module, "load_image_file", make_load_image_file_stub({"cat.png": (1024, 768)}))
response = client.post(
"/api/v1/recall/default",
json={"reference_images": [{"image_name": "cat.png"}]},
)
assert response.status_code == 200
assert "append" not in response.json()["parameters"]
def test_append_flag_not_persisted(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, mock_invoker: Invoker, client: TestClient
) -> None:
"""The flag is injected after the persistence loop — only real recall
parameters may be written to client state."""
monkeypatch.setattr(recall_module, "load_image_file", make_load_image_file_stub({"cat.png": (1024, 768)}))
persisted_keys: list[str] = []
monkeypatch.setattr(
mock_invoker.services.client_state_persistence,
"set_by_key",
lambda user_id, key, value: persisted_keys.append(key) or value,
)
response = client.post(
"/api/v1/recall/default?append=true",
json={"reference_images": [{"image_name": "cat.png"}]},
)
assert response.status_code == 200
assert persisted_keys == ["recall_reference_images"]
def test_append_and_strict_are_mutually_exclusive(
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
) -> None:
response = client.post(
"/api/v1/recall/default?strict=true&append=true",
json={"reference_images": [{"image_name": "cat.png"}]},
)
assert response.status_code == 400
assert "mutually exclusive" in response.json()["detail"]
@pytest.fixture
def mock_api_deps():
"""Patch ApiDependencies.invoker with a mock that simulates subfolder-aware image service."""
with patch("invokeai.app.api.routers.recall_parameters.ApiDependencies") as mock_deps:
invoker = MagicMock()
mock_deps.invoker = invoker
images_service = invoker.services.images
images_service.get_path.return_value = "/outputs/images/2026/04/05/test.png"
images_service.validate_path.return_value = True
pil_image = Image.new("RGB", (512, 768))
images_service.get_pil_image.return_value = pil_image
yield invoker
class TestLoadImageFile:
"""Unit tests for ``load_image_file`` — verifies it goes through the
subfolder-aware images service rather than the flat image_files service.
"""
def test_returns_image_info_for_subfolder_image(self, mock_api_deps: MagicMock):
"""load_image_file should work for images stored in subfolders."""
result = load_image_file("test.png")
assert result is not None
assert result["image_name"] == "test.png"
assert result["width"] == 512
assert result["height"] == 768
mock_api_deps.services.images.get_path.assert_called_once_with("test.png")
mock_api_deps.services.images.get_pil_image.assert_called_once_with("test.png")
def test_returns_none_when_file_not_found(self, mock_api_deps: MagicMock):
"""load_image_file should return None if the resolved path doesn't exist."""
mock_api_deps.services.images.validate_path.return_value = False
result = load_image_file("missing.png")
assert result is None
def test_returns_none_on_service_exception(self, mock_api_deps: MagicMock):
"""load_image_file should return None if the images service raises."""
mock_api_deps.services.images.get_path.side_effect = Exception("DB error")
result = load_image_file("broken.png")
assert result is None
def test_uses_images_service_not_image_files(self, mock_api_deps: MagicMock):
"""Regression: load_image_file must go through images service (subfolder-aware),
not image_files (flat-only)."""
load_image_file("test.png")
mock_api_deps.services.image_files.get.assert_not_called()
mock_api_deps.services.image_files.get_path.assert_not_called()
@@ -0,0 +1,40 @@
from unittest.mock import MagicMock
import pytest
from fastapi import HTTPException
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.api.routers.session_queue import enqueue_batch
from invokeai.app.services.session_queue.session_queue_common import DEFAULT_QUEUE_ID, Batch
from invokeai.app.services.shared.graph import Graph
class MockApiDependencies(ApiDependencies):
def __init__(self, invoker) -> None:
self.invoker = invoker
@pytest.fixture
def anyio_backend() -> str:
return "asyncio"
@pytest.mark.anyio
async def test_enqueue_batch_is_blocked_during_image_move_maintenance(
monkeypatch: pytest.MonkeyPatch, mock_invoker
) -> None:
mock_deps = MockApiDependencies(mock_invoker)
mock_invoker.services.image_moves = MagicMock()
mock_invoker.services.image_moves.is_maintenance_active.return_value = True
monkeypatch.setattr("invokeai.app.api.routers.image_move_maintenance.ApiDependencies", mock_deps)
with pytest.raises(HTTPException) as exc:
await enqueue_batch(
current_user=MagicMock(user_id="user-id"),
queue_id=DEFAULT_QUEUE_ID,
batch=Batch(graph=Graph()),
prepend=False,
)
assert exc.value.status_code == 409
assert exc.value.detail == "Image storage maintenance is active"
@@ -0,0 +1,191 @@
"""Tests for session queue item sanitization in multiuser mode."""
from datetime import datetime
import pytest
from invokeai.app.api.routers.session_queue import sanitize_queue_item_for_user
from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput, invocation, invocation_output
from invokeai.app.invocations.fields import InputField, OutputField
from invokeai.app.services.session_queue.session_queue_common import NodeFieldValue, SessionQueueItem
from invokeai.app.services.shared.graph import Graph, GraphExecutionState
from invokeai.app.services.shared.invocation_context import InvocationContext
# Define a minimal test invocation for the test
@invocation_output("test_sanitization_output")
class TestSanitizationInvocationOutput(BaseInvocationOutput):
value: str = OutputField(default="")
@invocation("test_sanitization", version="1.0.0")
class TestSanitizationInvocation(BaseInvocation):
test_field: str = InputField(default="")
def invoke(self, context: InvocationContext) -> TestSanitizationInvocationOutput:
return TestSanitizationInvocationOutput(value=self.test_field)
@pytest.fixture
def sample_session_queue_item() -> SessionQueueItem:
"""Create a sample queue item with full data for testing."""
graph = Graph()
# Add a simple node to the graph
graph.add_node(TestSanitizationInvocation(id="test_node", test_field="test value"))
session = GraphExecutionState(id="test_session", graph=graph)
# Create timestamps for the queue item
now = datetime.now()
return SessionQueueItem(
item_id=1,
status="pending",
batch_id="batch_123",
session_id="session_123",
queue_id="default",
user_id="user_123",
user_display_name="Test User",
user_email="test@example.com",
field_values=[
NodeFieldValue(node_path="test_node", field_name="test_field", value="sensitive prompt data"),
],
session=session,
workflow=None,
workflow_call_id="workflow-call-1",
parent_item_id=99,
parent_session_id="parent-session-1",
root_item_id=1,
workflow_call_depth=2,
created_at=now,
updated_at=now,
started_at=None,
completed_at=None,
)
def test_sanitize_queue_item_for_admin(sample_session_queue_item):
"""Test that admins can see all data regardless of user_id."""
result = sanitize_queue_item_for_user(
queue_item=sample_session_queue_item,
current_user_id="different_user",
is_admin=True,
)
# Admin should see everything
assert result.field_values is not None
assert len(result.field_values) == 1
assert result.session.graph.nodes is not None
assert len(result.session.graph.nodes) == 1
def test_sanitize_queue_item_for_owner(sample_session_queue_item):
"""Test that queue item owners can see their own data."""
result = sanitize_queue_item_for_user(
queue_item=sample_session_queue_item,
current_user_id="user_123", # Same as queue item user_id
is_admin=False,
)
# Owner should see everything
assert result.field_values is not None
assert len(result.field_values) == 1
assert result.session.graph.nodes is not None
assert len(result.session.graph.nodes) == 1
def test_sanitize_queue_item_for_different_user(sample_session_queue_item):
"""Test that non-admin users cannot see other users' sensitive data."""
result = sanitize_queue_item_for_user(
queue_item=sample_session_queue_item,
current_user_id="different_user",
is_admin=False,
)
# Non-admin viewing another user's item should have sanitized data
assert result.field_values is None
assert result.workflow is None
# Session should be replaced with empty/redacted graph
assert result.session.graph.nodes is not None
assert len(result.session.graph.nodes) == 0
assert result.session.id == "redacted"
# Identity and batch fields should be redacted
assert result.user_id == "redacted"
assert result.batch_id == "redacted"
assert result.session_id == "redacted"
assert result.user_display_name is None
assert result.user_email is None
assert result.origin is None
assert result.destination is None
assert result.error_type is None
assert result.error_message is None
assert result.error_traceback is None
def test_sanitize_preserves_non_sensitive_fields(sample_session_queue_item):
"""Test that sanitization preserves non-sensitive fields."""
result = sanitize_queue_item_for_user(
queue_item=sample_session_queue_item,
current_user_id="different_user",
is_admin=False,
)
# Non-sensitive fields should be preserved
assert result.item_id == 1
assert result.status == "pending"
assert result.queue_id == "default"
assert result.created_at is not None
assert result.updated_at is not None
# Sensitive fields should be redacted for non-owner non-admin
assert result.batch_id == "redacted"
assert result.session_id == "redacted"
assert result.user_id == "redacted"
assert result.user_display_name is None
assert result.user_email is None
def test_sanitize_redacts_workflow_call_metadata_for_different_user(sample_session_queue_item):
result = sanitize_queue_item_for_user(
queue_item=sample_session_queue_item,
current_user_id="different_user",
is_admin=False,
)
assert result.workflow_call_id is None
assert result.parent_item_id is None
assert result.parent_session_id is None
assert result.root_item_id is None
assert result.workflow_call_depth is None
def test_sanitize_system_user_item_for_non_admin(sample_session_queue_item):
"""Test that non-admin users cannot see sensitive data from System user's queue items."""
# Simulate a legacy System user queue item
system_item = sample_session_queue_item.model_copy(update={"user_id": "system"})
result = sanitize_queue_item_for_user(
queue_item=system_item,
current_user_id="non_admin_user",
is_admin=False,
)
# System user's sensitive fields should be sanitized for non-admin users
assert result.field_values is None
assert result.workflow is None
assert len(result.session.graph.nodes) == 0
def test_sanitize_system_user_item_for_admin(sample_session_queue_item):
"""Test that admin users can see full data from System user's queue items."""
system_item = sample_session_queue_item.model_copy(update={"user_id": "system"})
result = sanitize_queue_item_for_user(
queue_item=system_item,
current_user_id="admin_user",
is_admin=True,
)
# Admin should see everything including System user's data
assert result.field_values is not None
assert len(result.field_values) == 1
assert len(result.session.graph.nodes) == 1
@@ -0,0 +1,430 @@
"""Tests for session queue API behavior with workflow-call queue items."""
import logging
import uuid
from typing import Any
from unittest.mock import MagicMock
import pytest
from fastapi.testclient import TestClient
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.api_app import app
from invokeai.app.services.config.config_default import InvokeAIAppConfig
from invokeai.app.services.invocation_services import InvocationServices
from invokeai.app.services.invoker import Invoker
from invokeai.app.services.session_processor.session_processor_common import SessionProcessorStatus
from invokeai.app.services.session_queue.session_queue_sqlite import SqliteSessionQueue
from invokeai.app.services.shared.graph import Graph, GraphExecutionState
from invokeai.app.services.users.users_common import UserCreateRequest
from invokeai.app.services.workflow_records.workflow_records_sqlite import SqliteWorkflowRecordsStorage
from invokeai.backend.util.logging import InvokeAILogger
from tests.fixtures.sqlite_database import create_mock_sqlite_database
class MockApiDependencies(ApiDependencies):
invoker: Invoker
def __init__(self, invoker: Invoker) -> None:
self.invoker = invoker
@pytest.fixture
def setup_jwt_secret():
from invokeai.app.services.auth.token_service import set_jwt_secret
set_jwt_secret("test-secret-key-for-unit-tests-only-do-not-use-in-production")
@pytest.fixture
def client():
return TestClient(app)
@pytest.fixture
def mock_services() -> InvocationServices:
from invokeai.app.services.board_image_records.board_image_records_sqlite import SqliteBoardImageRecordStorage
from invokeai.app.services.board_records.board_records_sqlite import SqliteBoardRecordStorage
from invokeai.app.services.boards.boards_default import BoardService
from invokeai.app.services.bulk_download.bulk_download_default import BulkDownloadService
from invokeai.app.services.client_state_persistence.client_state_persistence_sqlite import (
ClientStatePersistenceSqlite,
)
from invokeai.app.services.image_records.image_records_sqlite import SqliteImageRecordStorage
from invokeai.app.services.images.images_default import ImageService
from invokeai.app.services.invocation_cache.invocation_cache_memory import MemoryInvocationCache
from invokeai.app.services.invocation_stats.invocation_stats_default import InvocationStatsService
from invokeai.app.services.users.users_default import UserService
from tests.test_nodes import TestEventService
configuration = InvokeAIAppConfig(use_memory_db=True, node_cache_size=0)
logger = InvokeAILogger.get_logger()
db = create_mock_sqlite_database(configuration, logger)
return InvocationServices(
board_image_records=SqliteBoardImageRecordStorage(db=db),
board_images=None, # type: ignore
board_records=SqliteBoardRecordStorage(db=db),
boards=BoardService(),
bulk_download=BulkDownloadService(),
configuration=configuration,
events=TestEventService(),
image_files=None, # type: ignore
image_records=SqliteImageRecordStorage(db=db),
images=ImageService(),
invocation_cache=MemoryInvocationCache(max_cache_size=0),
logger=logging, # type: ignore
model_images=None, # type: ignore
model_manager=None, # type: ignore
download_queue=None, # type: ignore
names=None, # type: ignore
performance_statistics=InvocationStatsService(),
session_processor=None, # type: ignore
session_queue=None, # type: ignore
urls=None, # type: ignore
workflow_records=SqliteWorkflowRecordsStorage(db=db),
tensors=None, # type: ignore
conditioning=None, # type: ignore
style_preset_records=None, # type: ignore
style_preset_image_files=None, # type: ignore
workflow_thumbnails=None, # type: ignore
model_relationship_records=None, # type: ignore
model_relationships=None, # type: ignore
client_state_persistence=ClientStatePersistenceSqlite(db=db),
users=UserService(db),
external_generation=None, # type: ignore
)
@pytest.fixture
def mock_invoker(mock_services: InvocationServices) -> Invoker:
invoker = Invoker(services=mock_services)
queue = SqliteSessionQueue(db=mock_services.board_records._db)
mock_services.session_queue = queue
mock_services.session_processor = MagicMock()
mock_services.session_processor.get_status.return_value = SessionProcessorStatus(
is_started=True, is_processing=False
)
queue.start(invoker)
return invoker
def _create_user(mock_invoker: Invoker, email: str, display_name: str, is_admin: bool = False) -> str:
user = mock_invoker.services.users.create(
UserCreateRequest(email=email, display_name=display_name, password="TestPass123", is_admin=is_admin)
)
return user.user_id
def _login(client: TestClient, email: str) -> str:
response = client.post("/api/v1/auth/login", json={"email": email, "password": "TestPass123", "remember_me": False})
assert response.status_code == 200
return response.json()["token"]
def _auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
@pytest.fixture
def enable_multiuser(monkeypatch: Any, mock_invoker: Invoker):
mock_invoker.services.configuration.multiuser = True
mock_deps = MockApiDependencies(mock_invoker)
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", mock_deps)
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps)
monkeypatch.setattr("invokeai.app.api.routers.session_queue.ApiDependencies", mock_deps)
yield
@pytest.fixture
def admin_token(setup_jwt_secret: None, enable_multiuser: Any, mock_invoker: Invoker, client: TestClient):
_create_user(mock_invoker, "admin@test.com", "Admin", is_admin=True)
return _login(client, "admin@test.com")
@pytest.fixture
def user1_token(enable_multiuser: Any, mock_invoker: Invoker, client: TestClient, admin_token: str):
_create_user(mock_invoker, "user1@test.com", "User One")
return _login(client, "user1@test.com")
@pytest.fixture
def user2_token(enable_multiuser: Any, mock_invoker: Invoker, client: TestClient, admin_token: str):
_create_user(mock_invoker, "user2@test.com", "User Two")
return _login(client, "user2@test.com")
def _insert_queue_item(
session_queue: SqliteSessionQueue,
*,
queue_id: str = "default",
user_id: str,
status: str,
session: GraphExecutionState | None = None,
workflow_call_id: str | None = None,
parent_item_id: int | None = None,
parent_session_id: str | None = None,
root_item_id: int | None = None,
workflow_call_depth: int | None = None,
) -> int:
session = session or GraphExecutionState(graph=Graph())
with session_queue._db.transaction() as cursor:
cursor.execute(
"""--sql
INSERT INTO session_queue (
queue_id, session, session_id, batch_id, field_values, priority, workflow, origin, destination,
retried_from_item_id, user_id, status, workflow_call_id, parent_item_id, parent_session_id,
root_item_id, workflow_call_depth
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
queue_id,
session.model_dump_json(warnings=False),
session.id,
str(uuid.uuid4()),
None,
0,
None,
None,
None,
None,
user_id,
status,
workflow_call_id,
parent_item_id,
parent_session_id,
root_item_id,
workflow_call_depth,
),
)
return cursor.lastrowid
def test_get_queue_status_reports_waiting_for_owner(
client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str
) -> None:
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
user2 = mock_invoker.services.users.get_by_email("user2@test.com")
assert user1 is not None and user2 is not None
_insert_queue_item(mock_invoker.services.session_queue, user_id=user1.user_id, status="waiting")
_insert_queue_item(mock_invoker.services.session_queue, user_id=user2.user_id, status="pending")
response = client.get("/api/v1/queue/default/status", headers=_auth(user1_token))
assert response.status_code == 200
payload = response.json()
assert payload["queue"]["waiting"] == 1
assert payload["queue"]["pending"] == 1
assert payload["queue"]["in_progress"] == 0
assert payload["queue"]["total"] == 2
assert payload["queue"]["user_pending"] == 0
assert payload["queue"]["user_in_progress"] == 0
assert payload["queue"]["item_id"] is None
def test_get_queue_item_sanitizes_workflow_call_metadata_for_non_owner(
client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str
) -> None:
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
assert user1 is not None
item_id = _insert_queue_item(
mock_invoker.services.session_queue,
user_id=user1.user_id,
status="waiting",
workflow_call_id="workflow-call-1",
parent_item_id=41,
parent_session_id="parent-session-1",
root_item_id=17,
workflow_call_depth=2,
)
response = client.get(f"/api/v1/queue/default/i/{item_id}", headers=_auth(user2_token))
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "waiting"
assert payload["item_id"] == item_id
assert payload["user_id"] == "redacted"
assert payload["batch_id"] == "redacted"
assert payload["session_id"] == "redacted"
assert payload.get("workflow_call_id") is None
assert payload.get("parent_item_id") is None
assert payload.get("parent_session_id") is None
assert payload.get("root_item_id") is None
assert payload.get("workflow_call_depth") is None
def test_retry_items_by_id_normalizes_child_to_root_at_router(
client: TestClient, mock_invoker: Invoker, user1_token: str
) -> None:
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
assert user1 is not None
root_item_id = _insert_queue_item(mock_invoker.services.session_queue, user_id=user1.user_id, status="failed")
child_item_id = _insert_queue_item(
mock_invoker.services.session_queue,
user_id=user1.user_id,
status="failed",
workflow_call_id="workflow-call-1",
parent_item_id=root_item_id,
parent_session_id="parent-session-1",
root_item_id=root_item_id,
workflow_call_depth=1,
)
response = client.put("/api/v1/queue/default/retry_items_by_id", headers=_auth(user1_token), json=[child_item_id])
assert response.status_code == 200
assert response.json()["retried_item_ids"] == [root_item_id]
def test_retry_items_by_id_rejects_items_from_other_queue(
client: TestClient, mock_invoker: Invoker, user1_token: str
) -> None:
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
assert user1 is not None
item_id = _insert_queue_item(
mock_invoker.services.session_queue,
queue_id="secondary",
user_id=user1.user_id,
status="failed",
)
response = client.put("/api/v1/queue/default/retry_items_by_id", headers=_auth(user1_token), json=[item_id])
assert response.status_code == 404
def test_retry_items_by_id_authorizes_workflow_call_root_owner(
client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str
) -> None:
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
user2 = mock_invoker.services.users.get_by_email("user2@test.com")
assert user1 is not None and user2 is not None
root_item_id = _insert_queue_item(mock_invoker.services.session_queue, user_id=user2.user_id, status="failed")
child_item_id = _insert_queue_item(
mock_invoker.services.session_queue,
user_id=user1.user_id,
status="failed",
workflow_call_id="workflow-call-1",
parent_item_id=root_item_id,
parent_session_id="parent-session-1",
root_item_id=root_item_id,
workflow_call_depth=1,
)
response = client.put("/api/v1/queue/default/retry_items_by_id", headers=_auth(user1_token), json=[child_item_id])
assert response.status_code == 403
assert mock_invoker.services.session_queue.get_queue_item(root_item_id).status == "failed"
def test_retry_items_by_id_skips_missing_items_and_retries_valid_items(
client: TestClient, mock_invoker: Invoker, user1_token: str
) -> None:
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
assert user1 is not None
root_item_id = _insert_queue_item(mock_invoker.services.session_queue, user_id=user1.user_id, status="failed")
response = client.put(
"/api/v1/queue/default/retry_items_by_id",
headers=_auth(user1_token),
json=[root_item_id + 9999, root_item_id],
)
assert response.status_code == 200
assert response.json()["retried_item_ids"] == [root_item_id]
def test_cancel_queue_item_cascades_to_waiting_parent_via_router(
client: TestClient, mock_invoker: Invoker, user1_token: str
) -> None:
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
assert user1 is not None
parent_item_id = _insert_queue_item(mock_invoker.services.session_queue, user_id=user1.user_id, status="waiting")
child_item_id = _insert_queue_item(
mock_invoker.services.session_queue,
user_id=user1.user_id,
status="pending",
workflow_call_id="workflow-call-1",
parent_item_id=parent_item_id,
parent_session_id="parent-session-1",
root_item_id=parent_item_id,
workflow_call_depth=1,
)
response = client.put(f"/api/v1/queue/default/i/{child_item_id}/cancel", headers=_auth(user1_token))
assert response.status_code == 200
assert response.json()["status"] == "canceled"
assert mock_invoker.services.session_queue.get_queue_item(parent_item_id).status == "canceled"
assert mock_invoker.services.session_queue.get_queue_item(child_item_id).status == "canceled"
def test_cancel_queue_item_rejects_item_from_other_queue(
client: TestClient, mock_invoker: Invoker, user1_token: str
) -> None:
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
assert user1 is not None
item_id = _insert_queue_item(
mock_invoker.services.session_queue,
queue_id="secondary",
user_id=user1.user_id,
status="pending",
)
response = client.put(f"/api/v1/queue/default/i/{item_id}/cancel", headers=_auth(user1_token))
assert response.status_code == 404
def test_delete_queue_item_rejects_item_from_other_queue(
client: TestClient, mock_invoker: Invoker, user1_token: str
) -> None:
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
assert user1 is not None
item_id = _insert_queue_item(
mock_invoker.services.session_queue,
queue_id="secondary",
user_id=user1.user_id,
status="failed",
)
response = client.delete(f"/api/v1/queue/default/i/{item_id}", headers=_auth(user1_token))
assert response.status_code == 404
def test_delete_queue_item_authorizes_workflow_call_root_owner(
client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str
) -> None:
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
user2 = mock_invoker.services.users.get_by_email("user2@test.com")
assert user1 is not None and user2 is not None
root_item_id = _insert_queue_item(mock_invoker.services.session_queue, user_id=user2.user_id, status="failed")
child_item_id = _insert_queue_item(
mock_invoker.services.session_queue,
user_id=user1.user_id,
status="failed",
workflow_call_id="workflow-call-1",
parent_item_id=root_item_id,
parent_session_id="parent-session-1",
root_item_id=root_item_id,
workflow_call_depth=1,
)
response = client.delete(f"/api/v1/queue/default/i/{child_item_id}", headers=_auth(user1_token))
assert response.status_code == 403
assert mock_invoker.services.session_queue.get_queue_item(root_item_id).status == "failed"
assert mock_invoker.services.session_queue.get_queue_item(child_item_id).status == "failed"
+414
View File
@@ -0,0 +1,414 @@
"""Router-level tests for /api/v1/style_presets.
Backed by a real SqliteStylePresetRecordsStorage from the shared conftest, so SQL
filtering and ownership rules are exercised end-to-end. style_preset_image_files
remains a MagicMock — file IO is not under test here.
Covers:
- Auth gating (CurrentUserOrDefault on CRUD/list/image, AdminUserOrDefault on export/import).
- Bug regression: json.JSONDecodeError must surface as 400 (not 500).
- Bug regression: malformed `data` payload after a valid image upload must NOT have
persisted the image (validation runs before image mutation).
- Cross-user isolation: owner / non-owner / admin / default / public matrix on
get, list, update, delete, and image fetch.
"""
import io
import json
from typing import Any
import pytest
from fastapi import status
from fastapi.testclient import TestClient
from PIL import Image
from invokeai.app.services.invoker import Invoker
from invokeai.app.services.style_preset_records.style_preset_records_common import (
PresetData,
PresetType,
StylePresetRecordDTO,
StylePresetWithoutId,
)
def _png_bytes() -> bytes:
buf = io.BytesIO()
Image.new("RGB", (4, 4), color="red").save(buf, format="PNG")
return buf.getvalue()
def _user_id(mock_invoker: Invoker, email: str) -> str:
user = mock_invoker.services.users.get_by_email(email)
assert user is not None, f"user {email} not seeded"
return user.user_id
def _seed(
mock_invoker: Invoker,
user_id: str,
name: str = "P",
is_public: bool = False,
preset_type: PresetType = PresetType.User,
) -> StylePresetRecordDTO:
return mock_invoker.services.style_preset_records.create(
StylePresetWithoutId(
name=name,
preset_data=PresetData(positive_prompt="p", negative_prompt="n"),
type=preset_type,
is_public=is_public,
),
user_id=user_id,
)
def _auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def _form(name: str = "X", preset_type: str = "user", is_public: bool = False) -> dict[str, str]:
return {
"data": json.dumps(
{
"name": name,
"positive_prompt": "p",
"negative_prompt": "n",
"type": preset_type,
"is_public": is_public,
}
)
}
# ----------------------------- Auth gating -----------------------------
@pytest.mark.parametrize(
("method", "path"),
[
("GET", "/api/v1/style_presets/i/preset-1"),
("DELETE", "/api/v1/style_presets/i/preset-1"),
("GET", "/api/v1/style_presets/"),
("GET", "/api/v1/style_presets/i/preset-1/image"),
("GET", "/api/v1/style_presets/export"),
],
)
def test_simple_routes_require_auth(enable_multiuser: Any, client: TestClient, method: str, path: str):
r = client.request(method, path)
assert r.status_code == status.HTTP_401_UNAUTHORIZED
def test_create_requires_auth(enable_multiuser: Any, client: TestClient):
r = client.post("/api/v1/style_presets/", data=_form())
assert r.status_code == status.HTTP_401_UNAUTHORIZED
def test_update_requires_auth(enable_multiuser: Any, client: TestClient):
r = client.patch("/api/v1/style_presets/i/preset-1", data=_form())
assert r.status_code == status.HTTP_401_UNAUTHORIZED
def test_import_requires_auth(enable_multiuser: Any, client: TestClient):
r = client.post(
"/api/v1/style_presets/import",
files={"file": ("x.csv", b"name,prompt,negative_prompt\n", "text/csv")},
)
assert r.status_code == status.HTTP_401_UNAUTHORIZED
def test_export_forbidden_for_regular_user(client: TestClient, user1_token: str):
r = client.get("/api/v1/style_presets/export", headers=_auth(user1_token))
assert r.status_code == status.HTTP_403_FORBIDDEN
def test_export_allowed_for_admin(client: TestClient, admin_token: str):
r = client.get("/api/v1/style_presets/export", headers=_auth(admin_token))
assert r.status_code == status.HTTP_200_OK
def test_import_forbidden_for_regular_user(client: TestClient, user1_token: str):
r = client.post(
"/api/v1/style_presets/import",
files={"file": ("x.csv", b"name,prompt,negative_prompt\n", "text/csv")},
headers=_auth(user1_token),
)
assert r.status_code == status.HTTP_403_FORBIDDEN
# ----------------------------- Bug B regression: JSONDecodeError → 400 -----------------------------
def test_create_malformed_json_returns_400(client: TestClient, user1_token: str, mock_invoker: Invoker):
r = client.post("/api/v1/style_presets/", data={"data": "not-valid-json"}, headers=_auth(user1_token))
assert r.status_code == status.HTTP_400_BAD_REQUEST
# Nothing persisted.
assert mock_invoker.services.style_preset_records.get_many(user_id=_user_id(mock_invoker, "user1@test.com")) == []
def test_update_malformed_json_returns_400(client: TestClient, user1_token: str, mock_invoker: Invoker):
# No need for a real record — JSON validation happens before the record is loaded.
r = client.patch("/api/v1/style_presets/i/some-id", data={"data": "not-valid-json"}, headers=_auth(user1_token))
assert r.status_code == status.HTTP_400_BAD_REQUEST
# ----------------------------- Bug C regression: validation before image mutation -----------------------------
def test_update_with_invalid_data_does_not_save_image(client: TestClient, user1_token: str, mock_invoker: Invoker):
"""A valid image plus malformed `data` must reject (400) AND must not have
persisted or deleted the preset image — the validation has to run first."""
r = client.patch(
"/api/v1/style_presets/i/preset-1",
data={"data": "not-valid-json"},
files={"image": ("x.png", _png_bytes(), "image/png")},
headers=_auth(user1_token),
)
assert r.status_code == status.HTTP_400_BAD_REQUEST
mock_invoker.services.style_preset_image_files.save.assert_not_called()
mock_invoker.services.style_preset_image_files.delete.assert_not_called()
def test_update_without_image_and_invalid_data_does_not_delete(
client: TestClient, user1_token: str, mock_invoker: Invoker
):
"""The pre-fix code would call `delete` on the image even though `data` is invalid."""
r = client.patch("/api/v1/style_presets/i/preset-1", data={"data": "not-valid-json"}, headers=_auth(user1_token))
assert r.status_code == status.HTTP_400_BAD_REQUEST
mock_invoker.services.style_preset_image_files.delete.assert_not_called()
# ----------------------------- Happy path -----------------------------
def test_create_with_valid_data_persists_record(client: TestClient, user1_token: str, mock_invoker: Invoker):
mock_invoker.services.style_preset_image_files.get_url.return_value = None
r = client.post("/api/v1/style_presets/", data=_form(name="Mine"), headers=_auth(user1_token))
assert r.status_code == status.HTTP_200_OK
user1 = _user_id(mock_invoker, "user1@test.com")
presets = mock_invoker.services.style_preset_records.get_many(user_id=user1)
names = [p.name for p in presets]
assert "Mine" in names
# New record is owned by user1.
owned = [p for p in presets if p.name == "Mine"]
assert owned[0].user_id == user1
assert owned[0].is_public is False
def test_update_with_valid_data_changes_record(client: TestClient, user1_token: str, mock_invoker: Invoker):
mock_invoker.services.style_preset_image_files.get_url.return_value = None
user1 = _user_id(mock_invoker, "user1@test.com")
seeded = _seed(mock_invoker, user1, name="Before")
r = client.patch(
f"/api/v1/style_presets/i/{seeded.id}",
data=_form(name="After"),
headers=_auth(user1_token),
)
assert r.status_code == status.HTTP_200_OK
refreshed = mock_invoker.services.style_preset_records.get(seeded.id)
assert refreshed.name == "After"
def test_update_with_non_image_returns_415(client: TestClient, user1_token: str, mock_invoker: Invoker):
user1 = _user_id(mock_invoker, "user1@test.com")
seeded = _seed(mock_invoker, user1, name="X")
r = client.patch(
f"/api/v1/style_presets/i/{seeded.id}",
data=_form(name="X"),
files={"image": ("x.txt", b"not an image", "text/plain")},
headers=_auth(user1_token),
)
assert r.status_code == status.HTTP_415_UNSUPPORTED_MEDIA_TYPE
mock_invoker.services.style_preset_image_files.save.assert_not_called()
# ----------------------------- Cross-user ownership policy -----------------------------
class TestOwnership:
def test_list_returns_only_own_plus_defaults_plus_public(
self, client: TestClient, user1_token: str, user2_token: str, mock_invoker: Invoker
):
u1 = _user_id(mock_invoker, "user1@test.com")
u2 = _user_id(mock_invoker, "user2@test.com")
_seed(mock_invoker, u1, name="u1-private")
_seed(mock_invoker, u1, name="u1-public", is_public=True)
_seed(mock_invoker, u2, name="u2-private")
_seed(mock_invoker, "system", name="default-A", preset_type=PresetType.Default)
mock_invoker.services.style_preset_image_files.get_url.return_value = None
r = client.get("/api/v1/style_presets/", headers=_auth(user1_token))
assert r.status_code == status.HTTP_200_OK
names = {p["name"] for p in r.json()}
assert "u1-private" in names
assert "u1-public" in names
assert "default-A" in names
assert "u2-private" not in names
def test_list_includes_other_users_public_preset(
self, client: TestClient, user1_token: str, user2_token: str, mock_invoker: Invoker
):
u1 = _user_id(mock_invoker, "user1@test.com")
_seed(mock_invoker, u1, name="u1-public", is_public=True)
mock_invoker.services.style_preset_image_files.get_url.return_value = None
r = client.get("/api/v1/style_presets/", headers=_auth(user2_token))
assert r.status_code == status.HTTP_200_OK
names = {p["name"] for p in r.json()}
assert "u1-public" in names
def test_get_other_users_private_is_forbidden(
self, client: TestClient, user1_token: str, user2_token: str, mock_invoker: Invoker
):
u1 = _user_id(mock_invoker, "user1@test.com")
seeded = _seed(mock_invoker, u1, name="private")
mock_invoker.services.style_preset_image_files.get_url.return_value = None
r = client.get(f"/api/v1/style_presets/i/{seeded.id}", headers=_auth(user2_token))
assert r.status_code == status.HTTP_403_FORBIDDEN
def test_get_other_users_public_is_allowed(
self, client: TestClient, user1_token: str, user2_token: str, mock_invoker: Invoker
):
u1 = _user_id(mock_invoker, "user1@test.com")
seeded = _seed(mock_invoker, u1, name="pub", is_public=True)
mock_invoker.services.style_preset_image_files.get_url.return_value = None
r = client.get(f"/api/v1/style_presets/i/{seeded.id}", headers=_auth(user2_token))
assert r.status_code == status.HTTP_200_OK
assert r.json()["name"] == "pub"
def test_get_default_is_allowed_for_any_user(self, client: TestClient, user1_token: str, mock_invoker: Invoker):
seeded = _seed(mock_invoker, "system", name="builtin", preset_type=PresetType.Default)
mock_invoker.services.style_preset_image_files.get_url.return_value = None
r = client.get(f"/api/v1/style_presets/i/{seeded.id}", headers=_auth(user1_token))
assert r.status_code == status.HTTP_200_OK
def test_update_other_users_preset_is_forbidden(
self, client: TestClient, user1_token: str, user2_token: str, mock_invoker: Invoker
):
u1 = _user_id(mock_invoker, "user1@test.com")
seeded = _seed(mock_invoker, u1, name="orig")
r = client.patch(
f"/api/v1/style_presets/i/{seeded.id}",
data=_form(name="hijack"),
headers=_auth(user2_token),
)
assert r.status_code == status.HTTP_403_FORBIDDEN
# Even a public preset cannot be modified by a non-owner.
seeded_public = _seed(mock_invoker, u1, name="orig-pub", is_public=True)
r = client.patch(
f"/api/v1/style_presets/i/{seeded_public.id}",
data=_form(name="hijack-pub"),
headers=_auth(user2_token),
)
assert r.status_code == status.HTTP_403_FORBIDDEN
def test_delete_other_users_preset_is_forbidden(
self, client: TestClient, user1_token: str, user2_token: str, mock_invoker: Invoker
):
u1 = _user_id(mock_invoker, "user1@test.com")
seeded = _seed(mock_invoker, u1, name="keep")
r = client.delete(f"/api/v1/style_presets/i/{seeded.id}", headers=_auth(user2_token))
assert r.status_code == status.HTTP_403_FORBIDDEN
# Record is still there.
still = mock_invoker.services.style_preset_records.get(seeded.id)
assert still.id == seeded.id
def test_update_default_preset_forbidden_for_non_admin(
self, client: TestClient, user1_token: str, mock_invoker: Invoker
):
seeded = _seed(mock_invoker, "system", name="builtin", preset_type=PresetType.Default)
r = client.patch(
f"/api/v1/style_presets/i/{seeded.id}",
data=_form(name="hijacked", preset_type="default"),
headers=_auth(user1_token),
)
assert r.status_code == status.HTTP_403_FORBIDDEN
def test_delete_default_preset_forbidden_for_non_admin(
self, client: TestClient, user1_token: str, mock_invoker: Invoker
):
seeded = _seed(mock_invoker, "system", name="builtin", preset_type=PresetType.Default)
r = client.delete(f"/api/v1/style_presets/i/{seeded.id}", headers=_auth(user1_token))
assert r.status_code == status.HTTP_403_FORBIDDEN
def test_create_default_preset_forbidden_for_non_admin(
self, client: TestClient, user1_token: str, mock_invoker: Invoker
):
mock_invoker.services.style_preset_image_files.get_url.return_value = None
r = client.post(
"/api/v1/style_presets/",
data=_form(name="Sneaky", preset_type="default"),
headers=_auth(user1_token),
)
assert r.status_code == status.HTTP_403_FORBIDDEN
def test_admin_can_get_any_preset(
self, client: TestClient, admin_token: str, user1_token: str, mock_invoker: Invoker
):
u1 = _user_id(mock_invoker, "user1@test.com")
seeded = _seed(mock_invoker, u1, name="private")
mock_invoker.services.style_preset_image_files.get_url.return_value = None
r = client.get(f"/api/v1/style_presets/i/{seeded.id}", headers=_auth(admin_token))
assert r.status_code == status.HTTP_200_OK
def test_admin_can_update_any_preset(
self, client: TestClient, admin_token: str, user1_token: str, mock_invoker: Invoker
):
u1 = _user_id(mock_invoker, "user1@test.com")
seeded = _seed(mock_invoker, u1, name="orig")
mock_invoker.services.style_preset_image_files.get_url.return_value = None
r = client.patch(
f"/api/v1/style_presets/i/{seeded.id}",
data=_form(name="admin-edit"),
headers=_auth(admin_token),
)
assert r.status_code == status.HTTP_200_OK
assert mock_invoker.services.style_preset_records.get(seeded.id).name == "admin-edit"
def test_admin_can_delete_default_preset(self, client: TestClient, admin_token: str, mock_invoker: Invoker):
seeded = _seed(mock_invoker, "system", name="del-default", preset_type=PresetType.Default)
r = client.delete(f"/api/v1/style_presets/i/{seeded.id}", headers=_auth(admin_token))
# delete returns 200 with no body (operation_id has no explicit status_code)
assert r.status_code in (status.HTTP_200_OK, status.HTTP_204_NO_CONTENT)
def test_admin_list_returns_everything(
self, client: TestClient, admin_token: str, user1_token: str, user2_token: str, mock_invoker: Invoker
):
u1 = _user_id(mock_invoker, "user1@test.com")
u2 = _user_id(mock_invoker, "user2@test.com")
_seed(mock_invoker, u1, name="u1-only")
_seed(mock_invoker, u2, name="u2-only")
mock_invoker.services.style_preset_image_files.get_url.return_value = None
r = client.get("/api/v1/style_presets/", headers=_auth(admin_token))
names = {p["name"] for p in r.json()}
assert {"u1-only", "u2-only"}.issubset(names)
def test_owner_can_flip_to_public(
self, client: TestClient, user1_token: str, user2_token: str, mock_invoker: Invoker
):
u1 = _user_id(mock_invoker, "user1@test.com")
seeded = _seed(mock_invoker, u1, name="will-be-public")
mock_invoker.services.style_preset_image_files.get_url.return_value = None
# user2 can't see it yet
r = client.get("/api/v1/style_presets/", headers=_auth(user2_token))
assert "will-be-public" not in {p["name"] for p in r.json()}
# user1 flips is_public=True
r = client.patch(
f"/api/v1/style_presets/i/{seeded.id}",
data=_form(name="will-be-public", is_public=True),
headers=_auth(user1_token),
)
assert r.status_code == status.HTTP_200_OK
assert r.json()["is_public"] is True
# user2 now sees it
r = client.get("/api/v1/style_presets/", headers=_auth(user2_token))
assert "will-be-public" in {p["name"] for p in r.json()}
def test_image_fetch_enforces_same_policy_as_get(
self, client: TestClient, user1_token: str, user2_token: str, mock_invoker: Invoker
):
u1 = _user_id(mock_invoker, "user1@test.com")
seeded = _seed(mock_invoker, u1, name="img-private")
r = client.get(f"/api/v1/style_presets/i/{seeded.id}/image", headers=_auth(user2_token))
assert r.status_code == status.HTTP_403_FORBIDDEN
mock_invoker.services.style_preset_image_files.get_path.assert_not_called()
@@ -0,0 +1,53 @@
"""Tests for `_load_settings_changed` — the predicate that decides whether to evict cached
model entries after an `update_model_record` call. Settings like `fp8_storage` and `cpu_only`
are baked into the loaded nn.Module at load time, so toggling them silently has no effect
until the cached entry is evicted. The predicate must catch changes to those settings while
ignoring changes that don't affect how the model loads (e.g. name, description).
"""
from types import SimpleNamespace
from invokeai.app.api.routers.model_manager import _load_settings_changed
def _config(*, fp8: bool | None = None, cpu_only: bool | None = None):
return SimpleNamespace(
cpu_only=cpu_only,
default_settings=SimpleNamespace(fp8_storage=fp8),
)
def test_no_change_returns_false():
assert _load_settings_changed(_config(fp8=True), _config(fp8=True)) is False
assert _load_settings_changed(_config(fp8=None), _config(fp8=None)) is False
def test_fp8_storage_toggle_returns_true():
"""The primary motivating case: a user toggling FP8 storage in the Model Manager must
drop the cached entry, otherwise inference keeps using the old (non-FP8) module."""
assert _load_settings_changed(_config(fp8=False), _config(fp8=True)) is True
assert _load_settings_changed(_config(fp8=True), _config(fp8=False)) is True
assert _load_settings_changed(_config(fp8=None), _config(fp8=True)) is True
assert _load_settings_changed(_config(fp8=True), _config(fp8=None)) is True
def test_cpu_only_toggle_returns_true():
"""`cpu_only` is also read by the loader (in `_get_execution_device`) and baked into the
cache entry's execution device — toggling it after load is a silent no-op without eviction."""
assert _load_settings_changed(_config(cpu_only=False), _config(cpu_only=True)) is True
assert _load_settings_changed(_config(cpu_only=True), _config(cpu_only=None)) is True
def test_missing_default_settings_is_handled():
"""default_settings can legitimately be None (e.g. a freshly identified config)."""
no_settings = SimpleNamespace(cpu_only=None, default_settings=None)
assert _load_settings_changed(no_settings, no_settings) is False
assert _load_settings_changed(no_settings, _config(fp8=True)) is True
def test_unrelated_field_does_not_trigger_invalidation():
"""A config missing the fp8/cpu_only attributes entirely (e.g. a model type with no such
fields) must not falsely report a change."""
bare_a = SimpleNamespace()
bare_b = SimpleNamespace()
assert _load_settings_changed(bare_a, bare_b) is False
+173
View File
@@ -0,0 +1,173 @@
"""Router-level tests for /api/v1/utilities.
Covers:
- Auth gating (CurrentUserOrDefault on all three utility routes).
- image-to-prompt: image read-access check must fire BEFORE the model is loaded,
so non-owners can't probe stored images.
- image-to-prompt: a missing image surfaces as 404, not 500.
"""
from typing import Any
from unittest.mock import MagicMock
import pytest
from fastapi import status
from fastapi.testclient import TestClient
from invokeai.app.services.image_records.image_records_common import ImageCategory, ResourceOrigin
from invokeai.app.services.invoker import Invoker
def _save_image(mock_invoker: Invoker, image_name: str, user_id: str) -> None:
mock_invoker.services.image_records.save(
image_name=image_name,
image_origin=ResourceOrigin.INTERNAL,
image_category=ImageCategory.GENERAL,
width=100,
height=100,
has_workflow=False,
user_id=user_id,
)
def _create_extra_user(mock_invoker: Invoker, email: str) -> str:
from invokeai.app.services.users.users_common import UserCreateRequest
user = mock_invoker.services.users.create(
UserCreateRequest(email=email, display_name=email, password="TestPass123", is_admin=False)
)
return user.user_id
# ----------------------------- Auth gating -----------------------------
@pytest.mark.parametrize(
"path,body",
[
("/api/v1/utilities/dynamicprompts", {"prompt": "hi"}),
("/api/v1/utilities/expand-prompt", {"prompt": "hi", "model_key": "m"}),
("/api/v1/utilities/image-to-prompt", {"image_name": "img-1", "model_key": "m"}),
],
)
def test_routes_require_auth(enable_multiuser: Any, client: TestClient, mock_invoker: Invoker, path: str, body: dict):
r = client.post(path, json=body)
assert r.status_code == status.HTTP_401_UNAUTHORIZED
mock_invoker.services.model_manager.store.get_model.assert_not_called()
def test_dynamicprompts_works_for_user(client: TestClient, user1_token: str):
r = client.post(
"/api/v1/utilities/dynamicprompts",
json={"prompt": "a {b|c}"},
headers={"Authorization": f"Bearer {user1_token}"},
)
assert r.status_code == status.HTTP_200_OK
body = r.json()
assert "prompts" in body
def test_dynamicprompts_unknown_wildcard_returns_error_without_hanging(client: TestClient, user1_token: str):
"""An unknown wildcard used as a variant value would otherwise loop forever in the combinatorial generator.
The endpoint must instead return promptly with a clear error and the original prompt echoed back.
"""
r = client.post(
"/api/v1/utilities/dynamicprompts",
json={"prompt": "{__random__8chan|fenster|stuff}"},
headers={"Authorization": f"Bearer {user1_token}"},
)
assert r.status_code == status.HTTP_200_OK
body = r.json()
assert body["error"] is not None
assert "random" in body["error"]
assert body["prompts"] == ["{__random__8chan|fenster|stuff}"]
def test_dynamicprompts_bare_unknown_wildcard_still_generates(client: TestClient, user1_token: str):
"""A wildcard used as plain literal text (not a variant value) does not hang and must not error."""
r = client.post(
"/api/v1/utilities/dynamicprompts",
json={"prompt": "a photo, __my_style__"},
headers={"Authorization": f"Bearer {user1_token}"},
)
assert r.status_code == status.HTTP_200_OK
body = r.json()
assert body["error"] is None
assert body["prompts"] # non-empty
assert all(p == "a photo, __my_style__" for p in body["prompts"])
def test_dynamicprompts_random_generator_ignores_unknown_wildcard(client: TestClient, user1_token: str):
"""The random generator handles unknown wildcards gracefully, so the guard must not fire for it."""
r = client.post(
"/api/v1/utilities/dynamicprompts",
json={"prompt": "{__random__8chan|fenster|stuff}", "combinatorial": False, "seed": 0},
headers={"Authorization": f"Bearer {user1_token}"},
)
assert r.status_code == status.HTTP_200_OK
body = r.json()
assert body["error"] is None
assert body["prompts"] # non-empty; the random generator expanded the variant
# ----------------------------- image_to_prompt: ownership / read-access -----------------------------
def test_image_to_prompt_forbidden_for_non_owner(
client: TestClient, user1_token: str, user2_token: str, mock_invoker: Invoker
):
"""A second user must not be able to read a private image via image-to-prompt."""
# Need to discover user1's id, then save an image under that id.
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
assert user1 is not None
_save_image(mock_invoker, "private-img.png", user1.user_id)
r = client.post(
"/api/v1/utilities/image-to-prompt",
json={"image_name": "private-img.png", "model_key": "some-key"},
headers={"Authorization": f"Bearer {user2_token}"},
)
assert r.status_code == status.HTTP_403_FORBIDDEN
# The model must not have been loaded — ownership must fire BEFORE inference.
mock_invoker.services.model_manager.store.get_model.assert_not_called()
def test_image_to_prompt_owner_reaches_model_load(client: TestClient, user1_token: str, mock_invoker: Invoker):
"""The owner passes the read-access check and the model load is attempted.
We force an UnknownModelException to keep the test light and assert 404."""
from invokeai.app.services.model_records.model_records_base import UnknownModelException
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
assert user1 is not None
_save_image(mock_invoker, "owned-img.png", user1.user_id)
mock_invoker.services.model_manager.store.get_model = MagicMock(side_effect=UnknownModelException("no such model"))
r = client.post(
"/api/v1/utilities/image-to-prompt",
json={"image_name": "owned-img.png", "model_key": "missing-model"},
headers={"Authorization": f"Bearer {user1_token}"},
)
assert r.status_code == status.HTTP_404_NOT_FOUND
mock_invoker.services.model_manager.store.get_model.assert_called_once()
def test_image_to_prompt_admin_can_access_any_image(
client: TestClient, admin_token: str, user1_token: str, mock_invoker: Invoker
):
from invokeai.app.services.model_records.model_records_base import UnknownModelException
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
assert user1 is not None
_save_image(mock_invoker, "user1-img.png", user1.user_id)
mock_invoker.services.model_manager.store.get_model = MagicMock(side_effect=UnknownModelException("no model"))
r = client.post(
"/api/v1/utilities/image-to-prompt",
json={"image_name": "user1-img.png", "model_key": "x"},
headers={"Authorization": f"Bearer {admin_token}"},
)
# Admin passes the read-access check; model loading then fails with 404.
assert r.status_code == status.HTTP_404_NOT_FOUND
+81
View File
@@ -0,0 +1,81 @@
"""Router-level tests for /api/v1/virtual_boards.
These routes already use CurrentUserOrDefault, but until now had no tests pinning
the anonymous-rejection + per-user filtering behavior.
"""
from typing import Any
from fastapi import status
from fastapi.testclient import TestClient
from invokeai.app.services.image_records.image_records_common import ImageCategory, ResourceOrigin
from invokeai.app.services.invoker import Invoker
def _save_image(mock_invoker: Invoker, image_name: str, user_id: str) -> None:
mock_invoker.services.image_records.save(
image_name=image_name,
image_origin=ResourceOrigin.INTERNAL,
image_category=ImageCategory.GENERAL,
width=10,
height=10,
has_workflow=False,
user_id=user_id,
)
def test_list_by_date_requires_auth(enable_multiuser: Any, client: TestClient):
r = client.get("/api/v1/virtual_boards/by_date")
assert r.status_code == status.HTTP_401_UNAUTHORIZED
def test_image_names_by_date_requires_auth(enable_multiuser: Any, client: TestClient):
r = client.get("/api/v1/virtual_boards/by_date/2026-05-18/image_names")
assert r.status_code == status.HTTP_401_UNAUTHORIZED
def test_user_sees_only_own_dates(client: TestClient, user1_token: str, user2_token: str, mock_invoker: Invoker):
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
user2 = mock_invoker.services.users.get_by_email("user2@test.com")
assert user1 is not None and user2 is not None
_save_image(mock_invoker, "u1-img-a.png", user1.user_id)
_save_image(mock_invoker, "u2-img-a.png", user2.user_id)
r1 = client.get(
"/api/v1/virtual_boards/by_date",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert r1.status_code == status.HTTP_200_OK
user1_counts = sum(b.get("image_count", 0) for b in r1.json())
r2 = client.get(
"/api/v1/virtual_boards/by_date",
headers={"Authorization": f"Bearer {user2_token}"},
)
assert r2.status_code == status.HTTP_200_OK
user2_counts = sum(b.get("image_count", 0) for b in r2.json())
# Each user sees only their single image — not the other user's.
assert user1_counts == 1
assert user2_counts == 1
def test_admin_sees_all_dates(
client: TestClient, admin_token: str, user1_token: str, user2_token: str, mock_invoker: Invoker
):
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
user2 = mock_invoker.services.users.get_by_email("user2@test.com")
assert user1 is not None and user2 is not None
_save_image(mock_invoker, "u1-shared.png", user1.user_id)
_save_image(mock_invoker, "u2-shared.png", user2.user_id)
r = client.get(
"/api/v1/virtual_boards/by_date",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert r.status_code == status.HTTP_200_OK
total = sum(b.get("image_count", 0) for b in r.json())
assert total >= 2 # admin sees images from both users
@@ -0,0 +1,118 @@
"""Tests for workflow CRUD live-update events with multiuser visibility rules."""
from typing import Any
from fastapi.testclient import TestClient
from tests.app.routers.test_workflows_multiuser import WORKFLOW_BODY
pytest_plugins = ("tests.app.routers.test_workflows_multiuser",)
def _auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def _event_names(events: list[Any]) -> list[str]:
return [event.__event_name__ for event in events]
def _get_last_event(events: list[Any], event_name: str) -> Any:
matching = [event for event in events if event.__event_name__ == event_name]
assert matching, f"Expected event '{event_name}' to be emitted"
return matching[-1]
def test_create_private_workflow_emits_owner_scoped_created_event(
client: TestClient, user1_token: str, mock_invoker: Any
) -> None:
response = client.post("/api/v1/workflows/", json={"workflow": WORKFLOW_BODY}, headers=_auth(user1_token))
assert response.status_code == 200
event = _get_last_event(mock_invoker.services.events.events, "workflow_created")
assert event.workflow_id == response.json()["workflow_id"]
assert event.user_id == response.json()["user_id"]
assert event.is_public is False
def test_update_workflow_emits_updated_event_with_previous_visibility(
client: TestClient, user1_token: str, mock_invoker: Any
) -> None:
create_response = client.post("/api/v1/workflows/", json={"workflow": WORKFLOW_BODY}, headers=_auth(user1_token))
workflow_id = create_response.json()["workflow_id"]
update_response = client.patch(
f"/api/v1/workflows/i/{workflow_id}",
json={"workflow": {**WORKFLOW_BODY, "id": workflow_id, "name": "Renamed Workflow"}},
headers=_auth(user1_token),
)
assert update_response.status_code == 200
event = _get_last_event(mock_invoker.services.events.events, "workflow_updated")
assert event.workflow_id == workflow_id
assert event.user_id == create_response.json()["user_id"]
assert event.old_is_public is False
assert event.new_is_public is False
def test_update_workflow_is_public_emits_visibility_transition_event(
client: TestClient, user1_token: str, mock_invoker: Any
) -> None:
create_response = client.post("/api/v1/workflows/", json={"workflow": WORKFLOW_BODY}, headers=_auth(user1_token))
workflow_id = create_response.json()["workflow_id"]
update_response = client.patch(
f"/api/v1/workflows/i/{workflow_id}/is_public",
json={"is_public": True},
headers=_auth(user1_token),
)
assert update_response.status_code == 200
event = _get_last_event(mock_invoker.services.events.events, "workflow_updated")
assert event.workflow_id == workflow_id
assert event.user_id == create_response.json()["user_id"]
assert event.old_is_public is False
assert event.new_is_public is True
def test_delete_workflow_emits_deleted_event_with_last_known_visibility(
client: TestClient, user1_token: str, mock_invoker: Any
) -> None:
create_response = client.post("/api/v1/workflows/", json={"workflow": WORKFLOW_BODY}, headers=_auth(user1_token))
workflow_id = create_response.json()["workflow_id"]
share_response = client.patch(
f"/api/v1/workflows/i/{workflow_id}/is_public",
json={"is_public": True},
headers=_auth(user1_token),
)
assert share_response.status_code == 200
delete_response = client.delete(f"/api/v1/workflows/i/{workflow_id}", headers=_auth(user1_token))
assert delete_response.status_code == 200
event = _get_last_event(mock_invoker.services.events.events, "workflow_deleted")
assert event.workflow_id == workflow_id
assert event.user_id == create_response.json()["user_id"]
assert event.is_public is True
def test_failed_update_does_not_emit_workflow_live_update_event(
client: TestClient, user1_token: str, user2_token: str, mock_invoker: Any
) -> None:
create_response = client.post("/api/v1/workflows/", json={"workflow": WORKFLOW_BODY}, headers=_auth(user1_token))
workflow_id = create_response.json()["workflow_id"]
before_event_names = _event_names(mock_invoker.services.events.events)
update_response = client.patch(
f"/api/v1/workflows/i/{workflow_id}",
json={"workflow": {**WORKFLOW_BODY, "id": workflow_id, "name": "Hijacked"}},
headers=_auth(user2_token),
)
assert update_response.status_code == 403
assert _event_names(mock_invoker.services.events.events) == before_event_names
@@ -0,0 +1,672 @@
"""Tests for multiuser workflow library functionality."""
import logging
from typing import Any
from unittest.mock import MagicMock
import pytest
from fastapi import status
from fastapi.testclient import TestClient
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.api_app import app
from invokeai.app.services.config.config_default import InvokeAIAppConfig
from invokeai.app.services.invocation_services import InvocationServices
from invokeai.app.services.invoker import Invoker
from invokeai.app.services.users.users_common import UserCreateRequest
from invokeai.app.services.workflow_records.workflow_records_sqlite import SqliteWorkflowRecordsStorage
from invokeai.backend.util.logging import InvokeAILogger
from tests.fixtures.sqlite_database import create_mock_sqlite_database
class MockApiDependencies(ApiDependencies):
invoker: Invoker
def __init__(self, invoker: Invoker) -> None:
self.invoker = invoker
WORKFLOW_BODY = {
"name": "Test Workflow",
"author": "",
"description": "A test workflow",
"version": "1.0.0",
"contact": "",
"tags": "",
"notes": "",
"nodes": [],
"edges": [],
"exposedFields": [],
"meta": {"version": "3.0.0", "category": "user"},
"id": None,
"form_fields": [],
}
@pytest.fixture
def setup_jwt_secret():
from invokeai.app.services.auth.token_service import set_jwt_secret
set_jwt_secret("test-secret-key-for-unit-tests-only-do-not-use-in-production")
@pytest.fixture
def client():
return TestClient(app)
@pytest.fixture
def mock_services() -> InvocationServices:
from invokeai.app.services.board_image_records.board_image_records_sqlite import SqliteBoardImageRecordStorage
from invokeai.app.services.board_records.board_records_sqlite import SqliteBoardRecordStorage
from invokeai.app.services.boards.boards_default import BoardService
from invokeai.app.services.bulk_download.bulk_download_default import BulkDownloadService
from invokeai.app.services.client_state_persistence.client_state_persistence_sqlite import (
ClientStatePersistenceSqlite,
)
from invokeai.app.services.image_records.image_records_sqlite import SqliteImageRecordStorage
from invokeai.app.services.images.images_default import ImageService
from invokeai.app.services.invocation_cache.invocation_cache_memory import MemoryInvocationCache
from invokeai.app.services.invocation_stats.invocation_stats_default import InvocationStatsService
from invokeai.app.services.users.users_default import UserService
from tests.test_nodes import TestEventService
configuration = InvokeAIAppConfig(use_memory_db=True, node_cache_size=0)
logger = InvokeAILogger.get_logger()
db = create_mock_sqlite_database(configuration, logger)
return InvocationServices(
board_image_records=SqliteBoardImageRecordStorage(db=db),
board_images=None, # type: ignore
board_records=SqliteBoardRecordStorage(db=db),
boards=BoardService(),
bulk_download=BulkDownloadService(),
configuration=configuration,
events=TestEventService(),
image_files=None, # type: ignore
image_records=SqliteImageRecordStorage(db=db),
images=ImageService(),
invocation_cache=MemoryInvocationCache(max_cache_size=0),
logger=logging, # type: ignore
model_images=None, # type: ignore
model_manager=None, # type: ignore
download_queue=None, # type: ignore
names=None, # type: ignore
performance_statistics=InvocationStatsService(),
session_processor=None, # type: ignore
session_queue=None, # type: ignore
urls=None, # type: ignore
workflow_records=SqliteWorkflowRecordsStorage(db=db),
tensors=None, # type: ignore
conditioning=None, # type: ignore
style_preset_records=None, # type: ignore
style_preset_image_files=None, # type: ignore
workflow_thumbnails=None, # type: ignore
model_relationship_records=None, # type: ignore
model_relationships=None, # type: ignore
client_state_persistence=ClientStatePersistenceSqlite(db=db),
users=UserService(db),
external_generation=None, # type: ignore
)
def create_test_user(mock_invoker: Invoker, email: str, display_name: str, is_admin: bool = False) -> str:
user_service = mock_invoker.services.users
user_data = UserCreateRequest(email=email, display_name=display_name, password="TestPass123", is_admin=is_admin)
user = user_service.create(user_data)
return user.user_id
def get_user_token(client: TestClient, email: str) -> str:
response = client.post(
"/api/v1/auth/login",
json={"email": email, "password": "TestPass123", "remember_me": False},
)
assert response.status_code == 200
return response.json()["token"]
@pytest.fixture
def enable_multiuser(monkeypatch: Any, mock_invoker: Invoker):
mock_invoker.services.configuration.multiuser = True
mock_workflow_thumbnails = MagicMock()
mock_workflow_thumbnails.get_url.return_value = None
mock_invoker.services.workflow_thumbnails = mock_workflow_thumbnails
mock_deps = MockApiDependencies(mock_invoker)
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", mock_deps)
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps)
monkeypatch.setattr("invokeai.app.api.routers.workflows.ApiDependencies", mock_deps)
yield
@pytest.fixture
def admin_token(setup_jwt_secret: None, enable_multiuser: Any, mock_invoker: Invoker, client: TestClient):
create_test_user(mock_invoker, "admin@test.com", "Admin", is_admin=True)
return get_user_token(client, "admin@test.com")
@pytest.fixture
def user1_token(enable_multiuser: Any, mock_invoker: Invoker, client: TestClient, admin_token: str):
create_test_user(mock_invoker, "user1@test.com", "User One", is_admin=False)
return get_user_token(client, "user1@test.com")
@pytest.fixture
def user2_token(enable_multiuser: Any, mock_invoker: Invoker, client: TestClient, admin_token: str):
create_test_user(mock_invoker, "user2@test.com", "User Two", is_admin=False)
return get_user_token(client, "user2@test.com")
def create_workflow(client: TestClient, token: str, workflow_body: dict[str, Any] | None = None) -> str:
response = client.post(
"/api/v1/workflows/",
json={"workflow": workflow_body or WORKFLOW_BODY},
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 200, response.text
return response.json()["workflow_id"]
# ---------------------------------------------------------------------------
# Auth tests
# ---------------------------------------------------------------------------
def test_list_workflows_requires_auth(enable_multiuser: Any, client: TestClient):
response = client.get("/api/v1/workflows/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_create_workflow_requires_auth(enable_multiuser: Any, client: TestClient):
response = client.post("/api/v1/workflows/", json={"workflow": WORKFLOW_BODY})
assert response.status_code == status.HTTP_401_UNAUTHORIZED
# ---------------------------------------------------------------------------
# Ownership isolation
# ---------------------------------------------------------------------------
def test_workflows_are_isolated_between_users(client: TestClient, user1_token: str, user2_token: str):
"""Users should only see their own workflows in list."""
# user1 creates a workflow
create_workflow(client, user1_token)
# user1 can see it
r1 = client.get("/api/v1/workflows/?categories=user", headers={"Authorization": f"Bearer {user1_token}"})
assert r1.status_code == 200
assert r1.json()["total"] == 1
# user2 cannot see user1's workflow
r2 = client.get("/api/v1/workflows/?categories=user", headers={"Authorization": f"Bearer {user2_token}"})
assert r2.status_code == 200
assert r2.json()["total"] == 0
def test_user_cannot_delete_another_users_workflow(client: TestClient, user1_token: str, user2_token: str):
workflow_id = create_workflow(client, user1_token)
response = client.delete(
f"/api/v1/workflows/i/{workflow_id}",
headers={"Authorization": f"Bearer {user2_token}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_user_cannot_update_another_users_workflow(client: TestClient, user1_token: str, user2_token: str):
workflow_id = create_workflow(client, user1_token)
updated = {**WORKFLOW_BODY, "id": workflow_id, "name": "Hijacked"}
response = client.patch(
f"/api/v1/workflows/i/{workflow_id}",
json={"workflow": updated},
headers={"Authorization": f"Bearer {user2_token}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_owner_can_delete_own_workflow(client: TestClient, user1_token: str):
workflow_id = create_workflow(client, user1_token)
response = client.delete(
f"/api/v1/workflows/i/{workflow_id}",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert response.status_code == 200
def test_admin_can_delete_any_workflow(client: TestClient, admin_token: str, user1_token: str):
workflow_id = create_workflow(client, user1_token)
response = client.delete(
f"/api/v1/workflows/i/{workflow_id}",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
def test_list_workflows_skips_stale_workflow_rows(
client: TestClient, user1_token: str, mock_invoker: Invoker, monkeypatch: Any
):
workflow_id = create_workflow(client, user1_token)
stale_id = "stale-workflow"
workflow_records = mock_invoker.services.workflow_records
existing = workflow_records.get(workflow_id)
original_get_many = workflow_records.get_many
original_get = workflow_records.get
def fake_get_many(*args, **kwargs):
results = original_get_many(*args, **kwargs)
return results.model_copy(
update={"items": [*results.items, results.items[0].model_copy(update={"workflow_id": stale_id})]}
)
def fake_get(requested_workflow_id: str):
if requested_workflow_id == stale_id:
from invokeai.app.services.workflow_records.workflow_records_common import WorkflowNotFoundError
raise WorkflowNotFoundError("stale")
return original_get(requested_workflow_id)
monkeypatch.setattr(workflow_records, "get_many", fake_get_many)
monkeypatch.setattr(workflow_records, "get", fake_get)
response = client.get("/api/v1/workflows/?categories=user", headers={"Authorization": f"Bearer {user1_token}"})
assert response.status_code == 200, response.text
payload = response.json()
assert payload["total"] == 1
assert [item["workflow_id"] for item in payload["items"]] == [existing.workflow_id]
# ---------------------------------------------------------------------------
# Shared workflow (is_public)
# ---------------------------------------------------------------------------
def test_update_is_public_owner_succeeds(client: TestClient, user1_token: str):
workflow_id = create_workflow(client, user1_token)
response = client.patch(
f"/api/v1/workflows/i/{workflow_id}/is_public",
json={"is_public": True},
headers={"Authorization": f"Bearer {user1_token}"},
)
assert response.status_code == 200
assert response.json()["is_public"] is True
def test_update_is_public_other_user_forbidden(client: TestClient, user1_token: str, user2_token: str):
workflow_id = create_workflow(client, user1_token)
response = client.patch(
f"/api/v1/workflows/i/{workflow_id}/is_public",
json={"is_public": True},
headers={"Authorization": f"Bearer {user2_token}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_public_workflow_visible_to_other_users(client: TestClient, user1_token: str, user2_token: str):
"""A shared (is_public=True) workflow should appear when filtering with is_public=true."""
workflow_id = create_workflow(client, user1_token)
# Make it public
client.patch(
f"/api/v1/workflows/i/{workflow_id}/is_public",
json={"is_public": True},
headers={"Authorization": f"Bearer {user1_token}"},
)
# user2 can see it through is_public=true filter
response = client.get(
"/api/v1/workflows/?categories=user&is_public=true",
headers={"Authorization": f"Bearer {user2_token}"},
)
assert response.status_code == 200
ids = [w["workflow_id"] for w in response.json()["items"]]
assert workflow_id in ids
def test_private_workflow_not_visible_to_other_users(client: TestClient, user1_token: str, user2_token: str):
"""A private (is_public=False) user workflow should NOT appear for another user."""
workflow_id = create_workflow(client, user1_token)
# user2 lists 'yours' style (their own workflows)
response = client.get(
"/api/v1/workflows/?categories=user",
headers={"Authorization": f"Bearer {user2_token}"},
)
assert response.status_code == 200
ids = [w["workflow_id"] for w in response.json()["items"]]
assert workflow_id not in ids
def test_public_workflow_still_in_owners_list(client: TestClient, user1_token: str):
"""A shared workflow should still appear in the owner's own workflow list."""
workflow_id = create_workflow(client, user1_token)
client.patch(
f"/api/v1/workflows/i/{workflow_id}/is_public",
json={"is_public": True},
headers={"Authorization": f"Bearer {user1_token}"},
)
# owner's 'yours' list (no is_public filter)
response = client.get(
"/api/v1/workflows/?categories=user",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert response.status_code == 200
ids = [w["workflow_id"] for w in response.json()["items"]]
assert workflow_id in ids
def test_workflow_has_user_id_and_is_public_fields(client: TestClient, user1_token: str):
"""Created workflow should return user_id and is_public fields."""
response = client.post(
"/api/v1/workflows/",
json={"workflow": WORKFLOW_BODY},
headers={"Authorization": f"Bearer {user1_token}"},
)
assert response.status_code == 200
data = response.json()
assert "user_id" in data
assert "is_public" in data
assert data["is_public"] is False
def test_list_workflows_includes_call_saved_workflow_compatibility(client: TestClient, user1_token: str):
compatible_workflow_id = create_workflow(
client,
user1_token,
{
**WORKFLOW_BODY,
"nodes": [
{
"id": "return",
"type": "invocation",
"position": {"x": 0, "y": 0},
"data": {
"id": "return",
"type": "workflow_return",
"version": "1.0.0",
"nodePack": "invokeai",
"label": "",
"notes": "",
"isOpen": True,
"isIntermediate": False,
"useCache": True,
"dynamicInputTemplates": {},
"inputs": {"collection": {"value": []}},
},
}
],
},
)
incompatible_workflow_id = create_workflow(client, user1_token)
response = client.get(
"/api/v1/workflows/?categories=user",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert response.status_code == 200
items_by_id = {item["workflow_id"]: item for item in response.json()["items"]}
assert items_by_id[compatible_workflow_id]["call_saved_workflow_compatibility"] == {
"is_callable": True,
"reason": "ok",
"message": None,
}
assert items_by_id[incompatible_workflow_id]["call_saved_workflow_compatibility"] == {
"is_callable": False,
"reason": "missing_workflow_return",
"message": "The workflow must contain exactly one workflow_return node.",
}
def _create_callable_workflow(client: TestClient, token: str, name: str) -> str:
return create_workflow(
client,
token,
{
**WORKFLOW_BODY,
"name": name,
"nodes": [
{
"id": "return",
"type": "invocation",
"position": {"x": 0, "y": 0},
"data": {
"id": "return",
"type": "workflow_return",
"version": "1.0.0",
"nodePack": "invokeai",
"label": "",
"notes": "",
"isOpen": True,
"isIntermediate": False,
"useCache": True,
"dynamicInputTemplates": {},
"inputs": {"values": {"value": []}},
},
}
],
},
)
def test_list_workflows_callable_filter_counts_only_callable_workflows(client: TestClient, user1_token: str):
callable_a = _create_callable_workflow(client, user1_token, "Callable A")
create_workflow(client, user1_token, {**WORKFLOW_BODY, "name": "Not Callable A"})
callable_b = _create_callable_workflow(client, user1_token, "Callable B")
create_workflow(client, user1_token, {**WORKFLOW_BODY, "name": "Not Callable B"})
callable_c = _create_callable_workflow(client, user1_token, "Callable C")
response = client.get(
"/api/v1/workflows/?categories=user&callable=true&per_page=2&page=0&order_by=name&direction=ASC",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert response.status_code == 200, response.text
payload = response.json()
assert payload["total"] == 3
assert payload["pages"] == 2
assert payload["per_page"] == 2
assert [item["workflow_id"] for item in payload["items"]] == [callable_a, callable_b]
assert {item["workflow_id"] for item in payload["items"]}.isdisjoint({callable_c})
assert all(item["call_saved_workflow_compatibility"]["is_callable"] for item in payload["items"])
def test_list_workflows_callable_filter_paginates_callable_workflows_after_filtering(
client: TestClient, user1_token: str
):
_create_callable_workflow(client, user1_token, "Callable A")
create_workflow(client, user1_token, {**WORKFLOW_BODY, "name": "Not Callable A"})
_create_callable_workflow(client, user1_token, "Callable B")
create_workflow(client, user1_token, {**WORKFLOW_BODY, "name": "Not Callable B"})
callable_c = _create_callable_workflow(client, user1_token, "Callable C")
response = client.get(
"/api/v1/workflows/?categories=user&callable=true&per_page=2&page=1&order_by=name&direction=ASC",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert response.status_code == 200, response.text
payload = response.json()
assert payload["total"] == 3
assert payload["pages"] == 2
assert payload["per_page"] == 2
assert [item["workflow_id"] for item in payload["items"]] == [callable_c]
assert all(item["call_saved_workflow_compatibility"]["is_callable"] for item in payload["items"])
def test_get_workflow_includes_call_saved_workflow_compatibility(client: TestClient, user1_token: str):
workflow_id = create_workflow(client, user1_token)
response = client.get(
f"/api/v1/workflows/i/{workflow_id}",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert response.status_code == 200
assert response.json()["call_saved_workflow_compatibility"] == {
"is_callable": False,
"reason": "missing_workflow_return",
"message": "The workflow must contain exactly one workflow_return node.",
}
# ---------------------------------------------------------------------------
# System-owned workflow visibility (regression tests for migration 30 fix)
# ---------------------------------------------------------------------------
def _insert_system_workflow(mock_invoker: Invoker, name: str = "Legacy Workflow", is_public: bool = True) -> str:
"""Insert a workflow owned by 'system' directly via the service layer, then set is_public."""
from invokeai.app.services.workflow_records.workflow_records_common import WorkflowWithoutID
wf = WorkflowWithoutID(**{**WORKFLOW_BODY, "name": name})
record = mock_invoker.services.workflow_records.create(workflow=wf, user_id="system")
if is_public:
mock_invoker.services.workflow_records.update_is_public(workflow_id=record.workflow_id, is_public=True)
return record.workflow_id
def test_system_public_workflow_visible_in_shared_listing(client: TestClient, user1_token: str, mock_invoker: Invoker):
"""After migration 30, system-owned public workflows should appear in the shared workflows listing."""
wf_id = _insert_system_workflow(mock_invoker, "Legacy Workflow")
response = client.get(
"/api/v1/workflows/?categories=user&is_public=true",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert response.status_code == 200
ids = [w["workflow_id"] for w in response.json()["items"]]
assert wf_id in ids
def test_system_public_workflow_not_in_your_workflows(client: TestClient, user1_token: str, mock_invoker: Invoker):
"""System-owned public workflows should NOT appear in 'Your Workflows' listing."""
wf_id = _insert_system_workflow(mock_invoker, "Legacy Workflow")
response = client.get(
"/api/v1/workflows/?categories=user",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert response.status_code == 200
ids = [w["workflow_id"] for w in response.json()["items"]]
assert wf_id not in ids
def test_admin_can_list_system_workflows(client: TestClient, admin_token: str, mock_invoker: Invoker):
"""Admins should see system-owned workflows in their listing."""
wf_id = _insert_system_workflow(mock_invoker, "Admin Visible Workflow")
response = client.get(
"/api/v1/workflows/?categories=user",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
ids = [w["workflow_id"] for w in response.json()["items"]]
assert wf_id in ids
def test_admin_can_update_system_workflow(client: TestClient, admin_token: str, mock_invoker: Invoker):
"""Admins should be able to update a system-owned workflow."""
wf_id = _insert_system_workflow(mock_invoker, "Editable Legacy")
# Get the full workflow to update it
get_resp = client.get(
f"/api/v1/workflows/i/{wf_id}",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert get_resp.status_code == 200
workflow_data = get_resp.json()["workflow"]
workflow_data["name"] = "Updated by Admin"
update_resp = client.patch(
f"/api/v1/workflows/i/{wf_id}",
json={"workflow": workflow_data},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert update_resp.status_code == 200
assert update_resp.json()["workflow"]["name"] == "Updated by Admin"
def test_admin_can_delete_system_workflow(client: TestClient, admin_token: str, mock_invoker: Invoker):
"""Admins should be able to delete a system-owned workflow."""
wf_id = _insert_system_workflow(mock_invoker, "Deletable Legacy")
response = client.delete(
f"/api/v1/workflows/i/{wf_id}",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
def test_regular_user_cannot_update_system_workflow(client: TestClient, user1_token: str, mock_invoker: Invoker):
"""Regular users should NOT be able to update a system-owned workflow."""
wf_id = _insert_system_workflow(mock_invoker, "Protected Legacy")
get_resp = client.get(
f"/api/v1/workflows/i/{wf_id}",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert get_resp.status_code == 200
workflow_data = get_resp.json()["workflow"]
workflow_data["name"] = "Hijacked"
update_resp = client.patch(
f"/api/v1/workflows/i/{wf_id}",
json={"workflow": workflow_data},
headers={"Authorization": f"Bearer {user1_token}"},
)
assert update_resp.status_code == status.HTTP_403_FORBIDDEN
def test_regular_user_cannot_delete_system_workflow(client: TestClient, user1_token: str, mock_invoker: Invoker):
"""Regular users should NOT be able to delete a system-owned workflow."""
wf_id = _insert_system_workflow(mock_invoker, "Undeletable Legacy")
response = client.delete(
f"/api/v1/workflows/i/{wf_id}",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
# ---------------------------------------------------------------------------
# Single-user mode: default ownership + sharing on create
# ---------------------------------------------------------------------------
@pytest.fixture
def single_user_mode(monkeypatch: Any, mock_invoker: Invoker):
"""Configure the app for single-user (legacy) mode."""
mock_invoker.services.configuration.multiuser = False
mock_workflow_thumbnails = MagicMock()
mock_workflow_thumbnails.get_url.return_value = None
mock_invoker.services.workflow_thumbnails = mock_workflow_thumbnails
mock_deps = MockApiDependencies(mock_invoker)
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", mock_deps)
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps)
monkeypatch.setattr("invokeai.app.api.routers.workflows.ApiDependencies", mock_deps)
yield
def test_single_user_create_workflow_owned_by_system_and_public(single_user_mode: Any, client: TestClient):
"""In single-user mode, newly created workflows should be owned by 'system' and shared (is_public=True)."""
response = client.post("/api/v1/workflows/", json={"workflow": WORKFLOW_BODY})
assert response.status_code == 200, response.text
payload = response.json()
assert payload["user_id"] == "system"
assert payload["is_public"] is True
def test_multiuser_create_workflow_owned_by_user_and_private(client: TestClient, user1_token: str):
"""In multiuser mode, newly created workflows should be owned by the creator and private (is_public=False)."""
response = client.post(
"/api/v1/workflows/",
json={"workflow": WORKFLOW_BODY},
headers={"Authorization": f"Bearer {user1_token}"},
)
assert response.status_code == 200, response.text
payload = response.json()
assert payload["user_id"] != "system"
assert payload["is_public"] is False
+1
View File
@@ -0,0 +1 @@
"""Tests for authentication services."""
+8
View File
@@ -0,0 +1,8 @@
import pytest
from invokeai.app.services.auth.token_service import set_jwt_secret
@pytest.fixture(autouse=True)
def setup_jwt_secret() -> None:
set_jwt_secret("test-secret-key-for-unit-tests-only-do-not-use-in-production")
@@ -0,0 +1,411 @@
"""Integration tests for multi-user data isolation.
Tests to ensure users can only access their own data and cannot access
other users' data unless explicitly shared.
"""
import os
from pathlib import Path
from typing import Any
import pytest
from fastapi.testclient import TestClient
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.api_app import app
from invokeai.app.services.board_records.board_records_common import BoardRecordOrderBy
from invokeai.app.services.invoker import Invoker
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
from invokeai.app.services.users.users_common import UserCreateRequest
@pytest.fixture(autouse=True, scope="module")
def client(invokeai_root_dir: Path) -> TestClient:
"""Create a test client for the FastAPI app."""
os.environ["INVOKEAI_ROOT"] = invokeai_root_dir.as_posix()
return TestClient(app)
@pytest.fixture(autouse=True)
def enable_multiuser_for_auth_tests(mock_invoker: Invoker) -> None:
"""Enable multiuser mode for auth tests.
Auth tests need multiuser mode enabled since the login/setup endpoints
return 403 when multiuser is disabled.
"""
mock_invoker.services.configuration.multiuser = True
class MockApiDependencies(ApiDependencies):
"""Mock API dependencies for testing."""
invoker: Invoker
def __init__(self, invoker) -> None:
self.invoker = invoker
def create_user_and_login(
mock_invoker: Invoker, client: TestClient, monkeypatch: Any, email: str, password: str, is_admin: bool = False
) -> tuple[str, str]:
"""Helper to create a user, login, and return (user_id, token)."""
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
user_service = mock_invoker.services.users
user_data = UserCreateRequest(
email=email,
display_name=f"User {email}",
password=password,
is_admin=is_admin,
)
user = user_service.create(user_data)
# Login to get token
response = client.post(
"/api/v1/auth/login",
json={
"email": email,
"password": password,
"remember_me": False,
},
)
assert response.status_code == 200
token = response.json()["token"]
return user.user_id, token
class TestBoardDataIsolation:
"""Tests for board data isolation between users."""
def test_user_can_only_see_own_boards(self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient):
"""Test that users can only see their own boards."""
monkeypatch.setattr("invokeai.app.api.routers.boards.ApiDependencies", MockApiDependencies(mock_invoker))
# Create two users
user1_id, user1_token = create_user_and_login(
mock_invoker, client, monkeypatch, "user1@example.com", "TestPass123"
)
user2_id, user2_token = create_user_and_login(
mock_invoker, client, monkeypatch, "user2@example.com", "TestPass123"
)
# Create board for user1
board_service = mock_invoker.services.boards
user1_board = board_service.create(board_name="User 1 Board", user_id=user1_id)
# Create board for user2
user2_board = board_service.create(board_name="User 2 Board", user_id=user2_id)
# User1 should only see their board
user1_boards = board_service.get_many(
user_id=user1_id,
is_admin=False,
order_by=BoardRecordOrderBy.CreatedAt,
direction=SQLiteDirection.Ascending,
)
user1_board_ids = [b.board_id for b in user1_boards.items]
assert user1_board.board_id in user1_board_ids
assert user2_board.board_id not in user1_board_ids
# User2 should only see their board
user2_boards = board_service.get_many(
user_id=user2_id,
is_admin=False,
order_by=BoardRecordOrderBy.CreatedAt,
direction=SQLiteDirection.Ascending,
)
user2_board_ids = [b.board_id for b in user2_boards.items]
assert user2_board.board_id in user2_board_ids
assert user1_board.board_id not in user2_board_ids
def test_user_cannot_access_other_user_board_directly(self, mock_invoker: Invoker):
"""Test that users cannot access other users' boards by ID."""
board_service = mock_invoker.services.boards
user_service = mock_invoker.services.users
# Create two users
user1_data = UserCreateRequest(
email="user1@example.com", display_name="User 1", password="TestPass123", is_admin=False
)
user1 = user_service.create(user1_data)
user2_data = UserCreateRequest(
email="user2@example.com", display_name="User 2", password="TestPass123", is_admin=False
)
user2 = user_service.create(user2_data)
# User1 creates a board
user1_board = board_service.create(board_name="User 1 Private Board", user_id=user1.user_id)
# User2 tries to access user1's board
# The get method should check ownership
try:
retrieved_board = board_service.get(board_id=user1_board.board_id, user_id=user2.user_id)
# If get doesn't check ownership, this test needs to be updated
# or the implementation needs to be fixed
if retrieved_board is not None:
# Board was retrieved - check if it's because of missing authorization check
# This would be a security issue that needs fixing
pytest.fail("User was able to access another user's board without authorization")
except Exception:
# Expected - user2 should not be able to access user1's board
pass
def test_admin_can_see_all_boards(self, mock_invoker: Invoker):
"""Test that admin users can see all boards."""
board_service = mock_invoker.services.boards
user_service = mock_invoker.services.users
# Create admin user
admin_data = UserCreateRequest(
email="admin@example.com", display_name="Admin", password="AdminPass123", is_admin=True
)
admin = user_service.create(admin_data)
# Create regular user
user_data = UserCreateRequest(
email="user@example.com", display_name="User", password="TestPass123", is_admin=False
)
user = user_service.create(user_data)
# User creates a board
board_service.create(board_name="User Board", user_id=user.user_id)
# Admin creates a board
board_service.create(board_name="Admin Board", user_id=admin.user_id)
# Admin should be able to get all boards (implementation dependent)
# Note: Current implementation may not have admin override for board listing
# This test documents expected behavior
class TestImageDataIsolation:
"""Tests for image data isolation between users."""
def test_user_images_isolated_from_other_users(self, mock_invoker: Invoker):
"""Test that users cannot see other users' images."""
user_service = mock_invoker.services.users
# Create two users
user1_data = UserCreateRequest(
email="user1@example.com", display_name="User 1", password="TestPass123", is_admin=False
)
user_service.create(user1_data)
user2_data = UserCreateRequest(
email="user2@example.com", display_name="User 2", password="TestPass123", is_admin=False
)
user_service.create(user2_data)
# Note: Image service tests would require actual image creation
# which is beyond the scope of basic security testing
# This test documents expected behavior:
# - Images should have user_id field
# - Image queries should filter by user_id
# - Users should not be able to access images by knowing the image_name
class TestWorkflowDataIsolation:
"""Tests for workflow data isolation between users."""
def test_user_workflows_isolated_from_other_users(self, mock_invoker: Invoker):
"""Test that users cannot see other users' private workflows."""
user_service = mock_invoker.services.users
# Create two users
user1_data = UserCreateRequest(
email="user1@example.com", display_name="User 1", password="TestPass123", is_admin=False
)
user_service.create(user1_data)
user2_data = UserCreateRequest(
email="user2@example.com", display_name="User 2", password="TestPass123", is_admin=False
)
user_service.create(user2_data)
# Note: Workflow service tests would require workflow creation
# This test documents expected behavior:
# - Workflows should have user_id and is_public fields
# - Private workflows should only be visible to owner
# - Public workflows should be visible to all users
class TestQueueDataIsolation:
"""Tests for session queue data isolation between users."""
def test_user_queue_items_isolated_from_other_users(self, mock_invoker: Invoker):
"""Test that users cannot see other users' queue items."""
user_service = mock_invoker.services.users
# Create two users
user1_data = UserCreateRequest(
email="user1@example.com", display_name="User 1", password="TestPass123", is_admin=False
)
user_service.create(user1_data)
user2_data = UserCreateRequest(
email="user2@example.com", display_name="User 2", password="TestPass123", is_admin=False
)
user_service.create(user2_data)
# Note: Queue service tests would require session creation
# This test documents expected behavior:
# - Queue items should have user_id field
# - Users should only see their own queue items
# - Admin should see all queue items
class TestSharedBoardAccess:
"""Tests for shared board functionality."""
@pytest.mark.skip(reason="Shared board functionality not yet fully implemented")
def test_shared_board_access(self, mock_invoker: Invoker):
"""Test that users can access boards shared with them."""
board_service = mock_invoker.services.boards
user_service = mock_invoker.services.users
# Create two users
user1_data = UserCreateRequest(
email="user1@example.com", display_name="User 1", password="TestPass123", is_admin=False
)
user1 = user_service.create(user1_data)
user2_data = UserCreateRequest(
email="user2@example.com", display_name="User 2", password="TestPass123", is_admin=False
)
user_service.create(user2_data)
# User1 creates a board
board_service.create(board_name="Shared Board", user_id=user1.user_id)
# User1 shares the board with user2
# (This functionality is not yet implemented)
# User2 should be able to see the shared board
# Expected behavior documented for future implementation
class TestAdminAuthorization:
"""Tests for admin-only functionality."""
def test_regular_user_cannot_create_admin(self, mock_invoker: Invoker):
"""Test that regular users cannot create admin accounts."""
user_service = mock_invoker.services.users
# Create first admin
admin_data = UserCreateRequest(
email="admin@example.com", display_name="Admin", password="AdminPass123", is_admin=True
)
user_service.create(admin_data)
# Try to create another admin (should fail)
with pytest.raises(ValueError, match="already exists"):
another_admin_data = UserCreateRequest(
email="another@example.com", display_name="Another Admin", password="AdminPass123"
)
user_service.create_admin(another_admin_data)
def test_regular_user_cannot_list_all_users(self, mock_invoker: Invoker):
"""Test that regular users cannot list all users.
Note: This depends on API endpoint implementation.
At the service level, list_users is available to all callers.
Authorization should be enforced at the API level.
"""
user_service = mock_invoker.services.users
# Create users
user1_data = UserCreateRequest(
email="user1@example.com", display_name="User 1", password="TestPass123", is_admin=False
)
user_service.create(user1_data)
# Service level does not enforce authorization
# API level should check if caller is admin before allowing user listing
user_service.list_users()
# This will succeed at service level - API must enforce auth
class TestDataIntegrity:
"""Tests for data integrity in multi-user scenarios."""
def test_user_deletion_cascades_to_owned_data(self, mock_invoker: Invoker):
"""Test that deleting a user also deletes their owned data."""
user_service = mock_invoker.services.users
board_service = mock_invoker.services.boards
# Create user
user_data = UserCreateRequest(
email="deleteme@example.com", display_name="Delete Me", password="TestPass123", is_admin=False
)
user = user_service.create(user_data)
# User creates a board
board = board_service.create(board_name="My Board", user_id=user.user_id)
# Delete user
user_service.delete(user.user_id)
# Board should be deleted too (CASCADE in database)
# Note: get_dto doesn't take user_id parameter, it gets the board by ID only
# We'll check that it raises an exception or returns None after cascade delete
try:
board_service.get_dto(board_id=board.board_id)
# If we get here, the board wasn't deleted - this is a failure
raise AssertionError("Board should have been deleted by CASCADE")
except Exception:
# Expected - board was deleted by CASCADE
pass
def test_concurrent_user_operations_maintain_isolation(self, mock_invoker: Invoker):
"""Test that concurrent operations from different users maintain data isolation.
This is a basic test - comprehensive concurrency testing would require
multiple threads/processes and more complex scenarios.
"""
user_service = mock_invoker.services.users
board_service = mock_invoker.services.boards
# Create two users
user1_data = UserCreateRequest(
email="user1@example.com", display_name="User 1", password="TestPass123", is_admin=False
)
user1 = user_service.create(user1_data)
user2_data = UserCreateRequest(
email="user2@example.com", display_name="User 2", password="TestPass123", is_admin=False
)
user2 = user_service.create(user2_data)
# Both users create boards
user1_board = board_service.create(board_name="User 1 Board", user_id=user1.user_id)
user2_board = board_service.create(board_name="User 2 Board", user_id=user2.user_id)
# Verify isolation is maintained
user1_boards = board_service.get_many(
user_id=user1.user_id,
is_admin=False,
order_by=BoardRecordOrderBy.CreatedAt,
direction=SQLiteDirection.Ascending,
)
user2_boards = board_service.get_many(
user_id=user2.user_id,
is_admin=False,
order_by=BoardRecordOrderBy.CreatedAt,
direction=SQLiteDirection.Ascending,
)
user1_board_ids = [b.board_id for b in user1_boards.items]
user2_board_ids = [b.board_id for b in user2_boards.items]
# Each user should only see their own board
assert user1_board.board_id in user1_board_ids
assert user2_board.board_id not in user1_board_ids
assert user2_board.board_id in user2_board_ids
assert user1_board.board_id not in user2_board_ids
@@ -0,0 +1,329 @@
"""Unit tests for password utilities."""
from invokeai.app.services.auth.password_utils import (
get_password_strength,
hash_password,
validate_password_strength,
verify_password,
)
class TestPasswordHashing:
"""Tests for password hashing functionality."""
def test_hash_password_returns_different_hash_each_time(self):
"""Test that hashing the same password twice produces different hashes (due to salt)."""
password = "TestPassword123"
hash1 = hash_password(password)
hash2 = hash_password(password)
assert hash1 != hash2
assert hash1 != password
assert hash2 != password
def test_hash_password_with_special_characters(self):
"""Test hashing passwords with special characters."""
password = "Test!@#$%^&*()_+{}[]|:;<>?,./~`"
hashed = hash_password(password)
assert hashed is not None
assert verify_password(password, hashed)
def test_hash_password_with_unicode(self):
"""Test hashing passwords with Unicode characters."""
password = "Test密码123パスワード"
hashed = hash_password(password)
assert hashed is not None
assert verify_password(password, hashed)
def test_hash_password_empty_string(self):
"""Test hashing empty password (should work but fail validation)."""
password = ""
hashed = hash_password(password)
assert hashed is not None
assert verify_password(password, hashed)
def test_hash_password_very_long(self):
"""Test hashing very long passwords (bcrypt has 72 byte limit)."""
# Create a password longer than 72 bytes
password = "A" * 100
hashed = hash_password(password)
assert hashed is not None
# Verify with original password
assert verify_password(password, hashed)
# Should also match the truncated version
assert verify_password("A" * 72, hashed)
def test_hash_password_with_newlines(self):
"""Test hashing passwords containing newlines."""
password = "Test\nPassword\n123"
hashed = hash_password(password)
assert hashed is not None
assert verify_password(password, hashed)
class TestPasswordVerification:
"""Tests for password verification functionality."""
def test_verify_password_correct(self):
"""Test verifying correct password."""
password = "TestPassword123"
hashed = hash_password(password)
assert verify_password(password, hashed) is True
def test_verify_password_incorrect(self):
"""Test verifying incorrect password."""
password = "TestPassword123"
hashed = hash_password(password)
assert verify_password("WrongPassword123", hashed) is False
def test_verify_password_case_sensitive(self):
"""Test that password verification is case-sensitive."""
password = "TestPassword123"
hashed = hash_password(password)
assert verify_password("testpassword123", hashed) is False
assert verify_password("TESTPASSWORD123", hashed) is False
def test_verify_password_whitespace_sensitive(self):
"""Test that whitespace matters in password verification."""
password = "TestPassword123"
hashed = hash_password(password)
assert verify_password(" TestPassword123", hashed) is False
assert verify_password("TestPassword123 ", hashed) is False
assert verify_password("Test Password123", hashed) is False
def test_verify_password_with_special_characters(self):
"""Test verifying passwords with special characters."""
password = "Test!@#$%^&*()_+"
hashed = hash_password(password)
assert verify_password(password, hashed) is True
assert verify_password("Test!@#$%^&*()_+X", hashed) is False
def test_verify_password_with_unicode(self):
"""Test verifying passwords with Unicode."""
password = "Test密码123"
hashed = hash_password(password)
assert verify_password(password, hashed) is True
assert verify_password("Test密码124", hashed) is False
def test_verify_password_empty_against_hashed(self):
"""Test verifying empty password."""
password = ""
hashed = hash_password(password)
assert verify_password("", hashed) is True
assert verify_password("notEmpty", hashed) is False
def test_verify_password_invalid_hash_format(self):
"""Test verifying password against invalid hash format."""
password = "TestPassword123"
# Should return False for invalid hash, not raise exception
assert verify_password(password, "not_a_valid_hash") is False
assert verify_password(password, "") is False
class TestPasswordStrengthValidation:
"""Tests for password strength validation."""
def test_validate_strong_password(self):
"""Test validating a strong password."""
valid, message = validate_password_strength("StrongPass123")
assert valid is True
assert message == ""
def test_validate_password_too_short(self):
"""Test validating password shorter than 8 characters."""
valid, message = validate_password_strength("Short1")
assert valid is False
assert "at least 8 characters" in message
def test_validate_password_minimum_length(self):
"""Test validating password with exactly 8 characters."""
valid, message = validate_password_strength("Pass123A")
assert valid is True
assert message == ""
def test_validate_password_no_uppercase(self):
"""Test validating password without uppercase letters."""
valid, message = validate_password_strength("lowercase123")
assert valid is False
assert "uppercase" in message.lower()
def test_validate_password_no_lowercase(self):
"""Test validating password without lowercase letters."""
valid, message = validate_password_strength("UPPERCASE123")
assert valid is False
assert "lowercase" in message.lower()
def test_validate_password_no_digits(self):
"""Test validating password without digits."""
valid, message = validate_password_strength("NoDigitsHere")
assert valid is False
assert "number" in message.lower()
def test_validate_password_with_special_characters(self):
"""Test that special characters are allowed but not required."""
# With special characters
valid, message = validate_password_strength("Pass!@#$123")
assert valid is True
# Without special characters (but meets other requirements)
valid, message = validate_password_strength("Password123")
assert valid is True
def test_validate_password_with_spaces(self):
"""Test validating password with spaces."""
# Password with spaces that meets requirements
valid, message = validate_password_strength("Pass Word 123")
assert valid is True
assert message == ""
def test_validate_password_unicode(self):
"""Test validating password with Unicode characters."""
# Unicode with uppercase, lowercase, and digits
valid, message = validate_password_strength("密码Pass123")
assert valid is True
def test_validate_password_empty(self):
"""Test validating empty password."""
valid, message = validate_password_strength("")
assert valid is False
assert "at least 8 characters" in message
def test_validate_password_all_requirements_barely_met(self):
"""Test password that barely meets all requirements."""
# 8 chars, 1 upper, 1 lower, 1 digit
valid, message = validate_password_strength("Passwor1")
assert valid is True
assert message == ""
def test_validate_password_very_long(self):
"""Test validating very long password."""
# Very long password that meets requirements
password = "A" * 50 + "a" * 50 + "1" * 50
valid, message = validate_password_strength(password)
assert valid is True
assert message == ""
class TestGetPasswordStrength:
"""Tests for get_password_strength function."""
def test_weak_password_too_short(self):
"""Test that passwords shorter than 8 characters are 'weak'."""
assert get_password_strength("Ab1") == "weak"
assert get_password_strength("Ab1defg") == "weak" # 7 chars
assert get_password_strength("") == "weak"
def test_moderate_password_missing_uppercase(self):
"""Test that 8+ char passwords missing uppercase are 'moderate'."""
assert get_password_strength("lowercase1") == "moderate"
def test_moderate_password_missing_lowercase(self):
"""Test that 8+ char passwords missing lowercase are 'moderate'."""
assert get_password_strength("UPPERCASE1") == "moderate"
def test_moderate_password_missing_digit(self):
"""Test that 8+ char passwords missing digits are 'moderate'."""
assert get_password_strength("NoDigitsHere") == "moderate"
def test_moderate_password_only_lowercase_and_digit(self):
"""Test that 8+ char passwords with only lowercase and digit are 'moderate'."""
assert get_password_strength("lowercase1") == "moderate"
def test_strong_password(self):
"""Test that 8+ char passwords with upper, lower, and digit are 'strong'."""
assert get_password_strength("StrongPass1") == "strong"
assert get_password_strength("Pass123A") == "strong"
def test_strong_password_with_special_chars(self):
"""Test that passwords meeting all requirements plus special chars are 'strong'."""
assert get_password_strength("Pass!@#$123") == "strong"
def test_exactly_8_characters_meeting_requirements(self):
"""Test that exactly 8 characters meeting requirements is 'strong'."""
assert get_password_strength("Pass123A") == "strong"
def test_exactly_8_characters_missing_uppercase(self):
"""Test that exactly 8 characters missing uppercase is 'moderate'."""
assert get_password_strength("pass123a") == "moderate"
def test_strength_progression(self):
"""Test that strength improves as requirements are met."""
# Too short - weak
assert get_password_strength("Abc1") == "weak"
# Long enough but only lowercase - moderate
assert get_password_strength("abcdefgh") == "moderate"
# Meets all requirements - strong
assert get_password_strength("Abcdefg1") == "strong"
class TestPasswordSecurityProperties:
"""Tests for security properties of password handling."""
def test_timing_attack_resistance_same_length(self):
"""Test that password verification takes similar time for correct and incorrect passwords.
Note: This is a basic check. Real timing attack resistance requires more sophisticated testing.
"""
import time
password = "TestPassword123"
hashed = hash_password(password)
# Measure time for correct password
start = time.perf_counter()
for _ in range(100):
verify_password(password, hashed)
correct_time = time.perf_counter() - start
# Measure time for incorrect password of same length
start = time.perf_counter()
for _ in range(100):
verify_password("WrongPassword12", hashed)
incorrect_time = time.perf_counter() - start
# Times should be relatively similar (within 50% difference)
# This is a loose check as bcrypt is designed to be slow and timing-resistant
ratio = max(correct_time, incorrect_time) / min(correct_time, incorrect_time)
assert ratio < 1.5, "Timing difference too large, potential timing attack vulnerability"
def test_different_hashes_for_same_password(self):
"""Test that the same password produces different hashes (salt randomization)."""
password = "TestPassword123"
hashes = {hash_password(password) for _ in range(10)}
# All hashes should be unique due to random salt
assert len(hashes) == 10
def test_hash_output_format(self):
"""Test that hash output follows bcrypt format."""
password = "TestPassword123"
hashed = hash_password(password)
# Bcrypt hashes start with $2b$ (or other valid bcrypt identifiers)
assert hashed.startswith("$2")
# Bcrypt hashes are 60 characters long
assert len(hashed) == 60
+474
View File
@@ -0,0 +1,474 @@
"""Performance tests for multiuser authentication system.
These tests measure the performance overhead of authentication and
ensure the system performs acceptably under load.
"""
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from logging import Logger
import pytest
from invokeai.app.services.auth.password_utils import hash_password, verify_password
from invokeai.app.services.auth.token_service import TokenData, create_access_token, verify_token
from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
from invokeai.app.services.users.users_common import UserCreateRequest
from invokeai.app.services.users.users_default import UserService
@pytest.fixture
def logger() -> Logger:
"""Create a logger for testing."""
return Logger("test_performance")
@pytest.fixture
def user_service(logger: Logger) -> UserService:
"""Create a user service with in-memory database for testing."""
db = SqliteDatabase(db_path=None, logger=logger, verbose=False)
# Create users table
db._conn.execute("""
CREATE TABLE users (
user_id TEXT NOT NULL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
display_name TEXT,
password_hash TEXT NOT NULL,
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
last_login_at DATETIME
);
""")
db._conn.commit()
return UserService(db)
class TestPasswordPerformance:
"""Tests for password hashing and verification performance."""
def test_password_hashing_performance(self):
"""Test that password hashing completes in reasonable time.
bcrypt is intentionally slow for security. Each hash should take
approximately 50-100ms on modern hardware.
"""
password = "TestPassword123"
iterations = 10
start_time = time.time()
for _ in range(iterations):
hash_password(password)
elapsed_time = time.time() - start_time
avg_time_ms = (elapsed_time / iterations) * 1000
# Each hash should take between 10ms and 500ms
# (bcrypt is designed to be slow, 50-100ms is typical)
assert 10 < avg_time_ms < 500, f"Password hashing took {avg_time_ms:.2f}ms per hash"
# Log performance for reference
print(f"\nPassword hashing performance: {avg_time_ms:.2f}ms per hash")
def test_password_verification_performance(self):
"""Test that password verification completes in reasonable time."""
password = "TestPassword123"
hashed = hash_password(password)
iterations = 10
start_time = time.time()
for _ in range(iterations):
verify_password(password, hashed)
elapsed_time = time.time() - start_time
avg_time_ms = (elapsed_time / iterations) * 1000
# Verification should take similar time to hashing
assert 10 < avg_time_ms < 500, f"Password verification took {avg_time_ms:.2f}ms per verification"
print(f"Password verification performance: {avg_time_ms:.2f}ms per verification")
def test_concurrent_password_operations(self):
"""Test password operations under concurrent load."""
password = "TestPassword123"
num_operations = 20
def hash_and_verify():
hashed = hash_password(password)
return verify_password(password, hashed)
start_time = time.time()
with ThreadPoolExecutor(max_workers=4) as executor:
futures = [executor.submit(hash_and_verify) for _ in range(num_operations)]
results = [future.result() for future in as_completed(futures)]
elapsed_time = time.time() - start_time
# All operations should succeed
assert all(results)
# Total time should be less than sequential time due to parallelization
print(f"Concurrent password operations ({num_operations}): {elapsed_time:.2f}s total")
class TestTokenPerformance:
"""Tests for JWT token performance."""
def test_token_creation_performance(self):
"""Test that token creation is fast."""
token_data = TokenData(
user_id="user123",
email="test@example.com",
is_admin=False,
)
iterations = 1000
start_time = time.time()
for _ in range(iterations):
create_access_token(token_data)
elapsed_time = time.time() - start_time
avg_time_ms = (elapsed_time / iterations) * 1000
# Token creation should be very fast (< 1ms per token)
assert avg_time_ms < 1.0, f"Token creation took {avg_time_ms:.3f}ms per token"
print(f"\nToken creation performance: {avg_time_ms:.3f}ms per token")
def test_token_verification_performance(self):
"""Test that token verification is fast."""
token_data = TokenData(
user_id="user123",
email="test@example.com",
is_admin=False,
)
token = create_access_token(token_data)
iterations = 1000
start_time = time.time()
for _ in range(iterations):
verify_token(token)
elapsed_time = time.time() - start_time
avg_time_ms = (elapsed_time / iterations) * 1000
# Token verification should be very fast (< 1ms per verification)
assert avg_time_ms < 1.0, f"Token verification took {avg_time_ms:.3f}ms per verification"
print(f"Token verification performance: {avg_time_ms:.3f}ms per verification")
def test_concurrent_token_operations(self):
"""Test token operations under concurrent load."""
token_data = TokenData(
user_id="user123",
email="test@example.com",
is_admin=False,
)
num_operations = 1000
def create_and_verify():
token = create_access_token(token_data)
verified = verify_token(token)
return verified is not None
start_time = time.time()
with ThreadPoolExecutor(max_workers=10) as executor:
futures = [executor.submit(create_and_verify) for _ in range(num_operations)]
results = [future.result() for future in as_completed(futures)]
elapsed_time = time.time() - start_time
# All operations should succeed
assert all(results)
ops_per_second = num_operations / elapsed_time
print(f"Concurrent token operations: {ops_per_second:.0f} ops/second")
# Should handle at least 1000 operations per second
assert ops_per_second > 1000, f"Only {ops_per_second:.0f} ops/second"
class TestAuthenticationOverhead:
"""Tests for overall authentication system overhead."""
def test_login_flow_performance(self, user_service: UserService):
"""Test complete login flow performance."""
# Create a user
user_data = UserCreateRequest(
email="perf@example.com",
display_name="Performance Test",
password="TestPass123",
is_admin=False,
)
user_service.create(user_data)
iterations = 10
start_time = time.time()
for _ in range(iterations):
# Simulate login flow
user = user_service.authenticate("perf@example.com", "TestPass123")
assert user is not None
# Create token
token_data = TokenData(
user_id=user.user_id,
email=user.email,
is_admin=user.is_admin,
)
token = create_access_token(token_data)
# Verify token
verified = verify_token(token)
assert verified is not None
elapsed_time = time.time() - start_time
avg_time_ms = (elapsed_time / iterations) * 1000
# Complete login flow should complete in reasonable time
# Most of the time is spent on password verification (50-100ms)
assert avg_time_ms < 500, f"Login flow took {avg_time_ms:.2f}ms"
print(f"\nComplete login flow performance: {avg_time_ms:.2f}ms per login")
def test_token_verification_overhead(self):
"""Measure overhead of token verification vs no auth."""
token_data = TokenData(
user_id="user123",
email="test@example.com",
is_admin=False,
)
token = create_access_token(token_data)
iterations = 10000
# Measure token verification time
start_time = time.time()
for _ in range(iterations):
verify_token(token)
verification_time = time.time() - start_time
# Measure baseline (minimal operation)
start_time = time.time()
for _ in range(iterations):
# Simulate minimal auth check
_ = token is not None
baseline_time = time.time() - start_time
overhead_ms = ((verification_time - baseline_time) / iterations) * 1000
# Overhead should be minimal (< 0.1ms per request)
assert overhead_ms < 0.1, f"Token verification adds {overhead_ms:.4f}ms overhead per request"
print(f"Token verification overhead: {overhead_ms:.4f}ms per request")
class TestUserServicePerformance:
"""Tests for user service performance."""
def test_user_creation_performance(self, user_service: UserService):
"""Test user creation performance."""
iterations = 10
start_time = time.time()
for i in range(iterations):
user_data = UserCreateRequest(
email=f"user{i}@example.com",
display_name=f"User {i}",
password="TestPass123",
is_admin=False,
)
user_service.create(user_data)
elapsed_time = time.time() - start_time
avg_time_ms = (elapsed_time / iterations) * 1000
# User creation includes password hashing, so should be ~50-150ms
assert avg_time_ms < 500, f"User creation took {avg_time_ms:.2f}ms per user"
print(f"\nUser creation performance: {avg_time_ms:.2f}ms per user")
def test_user_lookup_performance(self, user_service: UserService):
"""Test user lookup performance."""
# Create some users
for i in range(10):
user_data = UserCreateRequest(
email=f"lookup{i}@example.com",
display_name=f"Lookup User {i}",
password="TestPass123",
is_admin=False,
)
user_service.create(user_data)
iterations = 1000
# Test lookup by email
start_time = time.time()
for _ in range(iterations):
user_service.get_by_email("lookup5@example.com")
elapsed_time = time.time() - start_time
avg_time_ms = (elapsed_time / iterations) * 1000
# Lookup should be fast (< 1ms with proper indexing)
assert avg_time_ms < 5.0, f"User lookup took {avg_time_ms:.3f}ms per lookup"
print(f"User lookup by email performance: {avg_time_ms:.3f}ms per lookup")
def test_user_list_performance(self, user_service: UserService):
"""Test user list performance with many users."""
# Create many users
num_users = 100
for i in range(num_users):
user_data = UserCreateRequest(
email=f"listuser{i}@example.com",
display_name=f"List User {i}",
password="TestPass123",
is_admin=False,
)
user_service.create(user_data)
# Test listing users
iterations = 10
start_time = time.time()
for _ in range(iterations):
user_service.list_users(limit=50)
elapsed_time = time.time() - start_time
avg_time_ms = (elapsed_time / iterations) * 1000
# Listing users should be fast (< 10ms for reasonable page size)
assert avg_time_ms < 50.0, f"User listing took {avg_time_ms:.2f}ms"
print(f"User listing performance (50 users): {avg_time_ms:.2f}ms per query")
class TestConcurrentUserSessions:
"""Tests for concurrent user session handling."""
def test_multiple_concurrent_logins(self, user_service: UserService):
"""Test handling multiple concurrent user logins."""
# Create test users
num_users = 20
for i in range(num_users):
user_data = UserCreateRequest(
email=f"concurrent{i}@example.com",
display_name=f"Concurrent User {i}",
password="TestPass123",
is_admin=False,
)
user_service.create(user_data)
def authenticate_user(user_index: int):
# Authenticate
user = user_service.authenticate(f"concurrent{user_index}@example.com", "TestPass123")
if user is None:
return False
# Create token
token_data = TokenData(
user_id=user.user_id,
email=user.email,
is_admin=user.is_admin,
)
token = create_access_token(token_data)
# Verify token
verified = verify_token(token)
return verified is not None
start_time = time.time()
# Simulate concurrent logins
with ThreadPoolExecutor(max_workers=10) as executor:
futures = [executor.submit(authenticate_user, i) for i in range(num_users)]
results = [future.result() for future in as_completed(futures)]
elapsed_time = time.time() - start_time
# All logins should succeed
assert all(results), "Some concurrent logins failed"
print(f"\nConcurrent logins ({num_users} users): {elapsed_time:.2f}s total")
# Should complete in reasonable time
assert elapsed_time < 10.0, f"Concurrent logins took {elapsed_time:.2f}s"
@pytest.mark.slow
class TestScalabilityBenchmarks:
"""Scalability benchmarks (marked as slow tests)."""
def test_authentication_under_load(self, user_service: UserService):
"""Test authentication system under sustained load."""
# Create test users
num_users = 50
for i in range(num_users):
user_data = UserCreateRequest(
email=f"load{i}@example.com",
display_name=f"Load User {i}",
password="TestPass123",
is_admin=False,
)
user_service.create(user_data)
def simulate_user_activity(user_index: int, num_requests: int):
success_count = 0
for _ in range(num_requests):
# Authenticate
user = user_service.authenticate(f"load{user_index}@example.com", "TestPass123")
if user is None:
continue
# Create and verify token
token_data = TokenData(user_id=user.user_id, email=user.email, is_admin=user.is_admin)
token = create_access_token(token_data)
verified = verify_token(token)
if verified is not None:
success_count += 1
return success_count
# Simulate sustained load
requests_per_user = 5
total_requests = num_users * requests_per_user
start_time = time.time()
with ThreadPoolExecutor(max_workers=10) as executor:
futures = [executor.submit(simulate_user_activity, i, requests_per_user) for i in range(num_users)]
success_counts = [future.result() for future in as_completed(futures)]
elapsed_time = time.time() - start_time
total_success = sum(success_counts)
success_rate = (total_success / total_requests) * 100
requests_per_second = total_requests / elapsed_time
print("\nLoad test results:")
print(f" Total requests: {total_requests}")
print(f" Success rate: {success_rate:.1f}%")
print(f" Requests/second: {requests_per_second:.0f}")
print(f" Total time: {elapsed_time:.2f}s")
# Should maintain high success rate under load
assert success_rate > 95.0, f"Success rate only {success_rate:.1f}%"
# Should handle reasonable throughput
# Note: This is limited by bcrypt hashing speed
assert requests_per_second > 5.0, f"Only {requests_per_second:.1f} req/s"
+459
View File
@@ -0,0 +1,459 @@
"""Security tests for multiuser authentication system.
This module tests various security aspects including:
- SQL injection prevention
- Authorization bypass attempts
- Session security
- Input validation
"""
import os
from pathlib import Path
from typing import Any
import pytest
from fastapi.testclient import TestClient
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.api_app import app
from invokeai.app.services.invoker import Invoker
from invokeai.app.services.users.users_common import UserCreateRequest
@pytest.fixture(autouse=True, scope="module")
def client(invokeai_root_dir: Path) -> TestClient:
"""Create a test client for the FastAPI app."""
os.environ["INVOKEAI_ROOT"] = invokeai_root_dir.as_posix()
return TestClient(app)
@pytest.fixture(autouse=True)
def enable_multiuser_for_auth_tests(mock_invoker: Invoker) -> None:
"""Enable multiuser mode for auth tests.
Auth tests need multiuser mode enabled since the login/setup endpoints
return 403 when multiuser is disabled.
"""
mock_invoker.services.configuration.multiuser = True
class MockApiDependencies(ApiDependencies):
"""Mock API dependencies for testing."""
invoker: Invoker
def __init__(self, invoker) -> None:
self.invoker = invoker
def setup_test_user(mock_invoker: Invoker, email: str = "test@example.com", password: str = "TestPass123") -> str:
"""Helper to create a test user and return user_id."""
user_service = mock_invoker.services.users
user_data = UserCreateRequest(
email=email,
display_name="Test User",
password=password,
is_admin=False,
)
user = user_service.create(user_data)
return user.user_id
def setup_test_admin(mock_invoker: Invoker, email: str = "admin@example.com", password: str = "AdminPass123") -> str:
"""Helper to create a test admin user and return user_id."""
user_service = mock_invoker.services.users
user_data = UserCreateRequest(
email=email,
display_name="Admin User",
password=password,
is_admin=True,
)
user = user_service.create(user_data)
return user.user_id
class TestSQLInjectionPrevention:
"""Tests to ensure SQL injection attacks are prevented."""
def test_login_sql_injection_in_email(self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient):
"""Test that SQL injection in email field is prevented."""
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
# Create a legitimate user first
setup_test_user(mock_invoker, "legitimate@example.com", "TestPass123")
# Try SQL injection in email field
sql_injection_attempts = [
"' OR '1'='1",
"admin' --",
"' OR 1=1 --",
"'; DROP TABLE users; --",
"' UNION SELECT * FROM users --",
]
for injection_attempt in sql_injection_attempts:
response = client.post(
"/api/v1/auth/login",
json={
"email": injection_attempt,
"password": "TestPass123",
"remember_me": False,
},
)
# Should return 401 (invalid credentials) or 422 (validation error)
# Both are acceptable - the important thing is no SQL injection occurs
assert response.status_code in [401, 422], f"SQL injection attempt should be rejected: {injection_attempt}"
# Should NOT return 200 (success) or 500 (server error)
assert response.status_code != 200, f"SQL injection should not succeed: {injection_attempt}"
assert response.status_code != 500, f"SQL injection should not cause server error: {injection_attempt}"
def test_login_sql_injection_in_password(self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient):
"""Test that SQL injection in password field is prevented."""
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
# Create a legitimate user
setup_test_user(mock_invoker, "test@example.com", "TestPass123")
# Try SQL injection in password field
sql_injection_attempts = [
"' OR '1'='1",
"anything' OR '1'='1' --",
"' OR 1=1; DROP TABLE users; --",
]
for injection_attempt in sql_injection_attempts:
response = client.post(
"/api/v1/auth/login",
json={
"email": "test@example.com",
"password": injection_attempt,
"remember_me": False,
},
)
# Should fail authentication
assert response.status_code == 401, f"SQL injection attempt should be rejected: {injection_attempt}"
def test_user_service_sql_injection_in_email(self, mock_invoker: Invoker):
"""Test that user service prevents SQL injection in email lookups."""
user_service = mock_invoker.services.users
# Create a test user
setup_test_user(mock_invoker, "test@example.com", "TestPass123")
# Try SQL injection in get_by_email
sql_injection_attempts = [
"test@example.com' OR '1'='1",
"' OR 1=1 --",
"test@example.com'; DROP TABLE users; --",
]
for injection_attempt in sql_injection_attempts:
# Should return None (not found), not raise an error or return wrong user
user = user_service.get_by_email(injection_attempt)
assert user is None, f"SQL injection should not return a user: {injection_attempt}"
class TestAuthorizationBypass:
"""Tests to ensure authorization cannot be bypassed."""
def test_cannot_access_protected_endpoint_without_token(self, client: TestClient):
"""Test that protected endpoints require authentication."""
# Try to access protected endpoint without token
response = client.get("/api/v1/auth/me")
assert response.status_code == 401
def test_cannot_access_protected_endpoint_with_invalid_token(
self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient
):
"""Test that invalid tokens are rejected."""
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
invalid_tokens = [
"invalid_token",
"Bearer invalid_token",
"",
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.invalid.signature",
]
for token in invalid_tokens:
response = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"})
assert response.status_code == 401, f"Invalid token should be rejected: {token}"
def test_cannot_forge_admin_token(self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient):
"""Test that admin privileges cannot be forged by modifying tokens."""
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
# Create a regular user and login
setup_test_user(mock_invoker, "regular@example.com", "TestPass123")
login_response = client.post(
"/api/v1/auth/login",
json={
"email": "regular@example.com",
"password": "TestPass123",
"remember_me": False,
},
)
token = login_response.json()["token"]
# Try to modify the token to gain admin privileges
# (In practice, this should fail signature verification)
parts = token.split(".")
if len(parts) == 3:
# Decode the payload, modify it, and re-encode
import base64
import json
# Add padding if necessary
payload_b64 = parts[1]
padding = 4 - len(payload_b64) % 4
if padding != 4:
payload_b64 += "=" * padding
# Decode payload
try:
payload_bytes = base64.urlsafe_b64decode(payload_b64)
payload_data = json.loads(payload_bytes)
# Modify is_admin to true
payload_data["is_admin"] = True
# Re-encode
modified_payload_bytes = json.dumps(payload_data).encode()
modified_payload_b64 = base64.urlsafe_b64encode(modified_payload_bytes).decode().rstrip("=")
# Create forged token with modified payload but original signature
modified_token = f"{parts[0]}.{modified_payload_b64}.{parts[2]}"
# Attempt to use modified token
response = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {modified_token}"})
# Should be rejected (invalid signature)
assert response.status_code == 401
except Exception:
# If we can't decode/modify the token, that's fine - just skip this part of the test
pass
def test_regular_user_cannot_create_admin(self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient):
"""Test that regular users cannot create admin users."""
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
# This test would require user management endpoints to be implemented
# For now, we test at the service level
user_service = mock_invoker.services.users
# Create a regular user
regular_user_data = UserCreateRequest(
email="regular@example.com",
display_name="Regular User",
password="TestPass123",
is_admin=False,
)
user_service.create(regular_user_data)
# Try to create an admin user (should only be possible through setup or by existing admin)
# The create_admin method checks if an admin already exists
admin_data = UserCreateRequest(
email="sneaky@example.com",
display_name="Sneaky Admin",
password="TestPass123",
)
# First create an actual admin
setup_test_admin(mock_invoker, "realadmin@example.com", "AdminPass123")
# Now trying to create another admin should fail
with pytest.raises(ValueError, match="already exists"):
user_service.create_admin(admin_data)
class TestSessionSecurity:
"""Tests for session and token security."""
def test_token_expires_after_time(self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient):
"""Test that tokens expire after their validity period."""
from datetime import timedelta
from invokeai.app.services.auth.token_service import TokenData, create_access_token
# Create a token that expires quickly
token_data = TokenData(
user_id="user123",
email="test@example.com",
is_admin=False,
)
# Create token with 10 millisecond expiration
expired_token = create_access_token(token_data, expires_delta=timedelta(milliseconds=10))
# Wait for expiration (wait longer than expiration time)
import time
time.sleep(0.02)
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
# Try to use expired token
response = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {expired_token}"})
assert response.status_code == 401
def test_logout_invalidates_session(self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient):
"""Test that logout invalidates the session.
Note: Current implementation uses JWT which is stateless.
This test documents expected behavior for future server-side session tracking.
"""
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
# Create user and login
setup_test_user(mock_invoker, "test@example.com", "TestPass123")
login_response = client.post(
"/api/v1/auth/login",
json={
"email": "test@example.com",
"password": "TestPass123",
"remember_me": False,
},
)
token = login_response.json()["token"]
# Logout
logout_response = client.post("/api/v1/auth/logout", headers={"Authorization": f"Bearer {token}"})
assert logout_response.status_code == 200
# Note: With JWT, the token is still technically valid until expiration
# For true session invalidation, server-side session tracking would be needed
class TestInputValidation:
"""Tests for input validation and sanitization."""
def test_email_validation_on_login(self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient):
"""Test that email validation is enforced on login."""
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
# Invalid email formats should be rejected by pydantic validation
invalid_emails = [
"not_an_email",
"@example.com",
"user@",
"user @example.com", # space in email
"../../../etc/passwd", # path traversal attempt
]
for invalid_email in invalid_emails:
response = client.post(
"/api/v1/auth/login",
json={
"email": invalid_email,
"password": "TestPass123",
"remember_me": False,
},
)
# Should return 422 (validation error) or 401 (invalid credentials)
assert response.status_code in [401, 422], f"Invalid email should be rejected: {invalid_email}"
def test_xss_prevention_in_user_data(self, mock_invoker: Invoker):
"""Test that XSS attempts in user data are handled safely.
Note: Database storage uses parameterized queries which prevent XSS.
This test ensures data is stored and retrieved without executing scripts.
"""
user_service = mock_invoker.services.users
# Try to create user with XSS payload in display name
xss_payloads = [
"<script>alert('xss')</script>",
"'; alert('xss'); //",
"<img src=x onerror=alert('xss')>",
]
for payload in xss_payloads:
user_data = UserCreateRequest(
email=f"xss{hash(payload)}@example.com", # unique email
display_name=payload,
password="TestPass123",
is_admin=False,
)
# Should not raise an error - data is stored as-is
user = user_service.create(user_data)
# Verify data is stored exactly as provided (not executed or modified)
assert user.display_name == payload
# Cleanup
user_service.delete(user.user_id)
def test_path_traversal_prevention(self, mock_invoker: Invoker):
"""Test that path traversal attempts in user input are handled."""
user_service = mock_invoker.services.users
# Path traversal attempts
path_traversal_attempts = [
"../../../etc/passwd",
"..\\..\\..\\windows\\system32",
"user/../../../secret",
]
for attempt in path_traversal_attempts:
# These should be stored as literal strings, not interpreted as paths
user_data = UserCreateRequest(
email=f"path{hash(attempt)}@example.com",
display_name=attempt,
password="TestPass123",
is_admin=False,
)
user = user_service.create(user_data)
assert user.display_name == attempt
# Cleanup
user_service.delete(user.user_id)
class TestRateLimiting:
"""Tests for rate limiting and brute force protection.
Note: Rate limiting is not currently implemented in the codebase.
These tests document expected behavior for future implementation.
"""
@pytest.mark.skip(reason="Rate limiting not yet implemented")
def test_login_rate_limiting(self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient):
"""Test that excessive login attempts are rate limited."""
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
setup_test_user(mock_invoker, "test@example.com", "TestPass123")
# Try many login attempts with wrong password
for i in range(20):
response = client.post(
"/api/v1/auth/login",
json={
"email": "test@example.com",
"password": "WrongPassword",
"remember_me": False,
},
)
if i < 10:
# First attempts should return 401
assert response.status_code == 401
else:
# After many attempts, should be rate limited (429)
# This is expected behavior for future implementation
pass
@@ -0,0 +1,371 @@
"""Unit tests for JWT token service."""
import time
from datetime import timedelta
import pytest
from invokeai.app.services.auth.token_service import TokenData, create_access_token, set_jwt_secret, verify_token
@pytest.fixture(scope="module", autouse=True)
def setup_jwt_secret():
"""Set up JWT secret for all tests in this module."""
# Use a test secret key
set_jwt_secret("test-secret-key-for-unit-tests-only-do-not-use-in-production")
# Minimum token length to safely modify middle characters for testing
# JWT tokens have format header.payload.signature and are typically >180 characters
MIN_TOKEN_LENGTH_FOR_MODIFICATION = 50
# Minimum signature length to safely modify middle characters for testing
# JWT signatures are typically 43 characters (base64-encoded HMAC-SHA256)
MIN_SIGNATURE_LENGTH_FOR_MODIFICATION = 10
class TestTokenCreation:
"""Tests for JWT token creation."""
def test_create_access_token_basic(self):
"""Test creating a basic access token."""
token_data = TokenData(
user_id="user123",
email="test@example.com",
is_admin=False,
)
token = create_access_token(token_data)
assert token is not None
assert isinstance(token, str)
assert len(token) > 0
def test_create_access_token_with_expiration(self):
"""Test creating token with custom expiration."""
token_data = TokenData(
user_id="user123",
email="test@example.com",
is_admin=False,
)
token = create_access_token(token_data, expires_delta=timedelta(hours=1))
assert token is not None
# Verify token is valid
verified_data = verify_token(token)
assert verified_data is not None
assert verified_data.user_id == "user123"
def test_create_access_token_admin_user(self):
"""Test creating token for admin user."""
token_data = TokenData(
user_id="admin123",
email="admin@example.com",
is_admin=True,
)
token = create_access_token(token_data)
verified_data = verify_token(token)
assert verified_data is not None
assert verified_data.is_admin is True
def test_create_access_token_preserves_all_data(self):
"""Test that all token data is preserved."""
token_data = TokenData(
user_id="user_with_complex_id_12345",
email="complex.email+tag@example.com",
is_admin=False,
)
token = create_access_token(token_data)
verified_data = verify_token(token)
assert verified_data is not None
assert verified_data.user_id == token_data.user_id
assert verified_data.email == token_data.email
assert verified_data.is_admin == token_data.is_admin
def test_create_access_token_different_each_time(self):
"""Test that creating token with same data produces different tokens (due to timestamps)."""
token_data = TokenData(
user_id="user123",
email="test@example.com",
is_admin=False,
)
# Create tokens with different expiration times to ensure uniqueness
token1 = create_access_token(token_data, expires_delta=timedelta(hours=1))
token2 = create_access_token(token_data, expires_delta=timedelta(hours=2))
# Tokens should be different due to different exp timestamps
assert token1 != token2
class TestTokenVerification:
"""Tests for JWT token verification."""
def test_verify_valid_token(self):
"""Test verifying a valid token."""
token_data = TokenData(
user_id="user123",
email="test@example.com",
is_admin=False,
)
token = create_access_token(token_data)
verified_data = verify_token(token)
assert verified_data is not None
assert verified_data.user_id == "user123"
assert verified_data.email == "test@example.com"
assert verified_data.is_admin is False
def test_verify_invalid_token(self):
"""Test verifying an invalid token."""
verified_data = verify_token("invalid_token_string")
assert verified_data is None
def test_verify_malformed_token(self):
"""Test verifying malformed tokens."""
malformed_tokens = [
"",
"not.a.token",
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.invalid",
"header.payload", # Missing signature
]
for token in malformed_tokens:
verified_data = verify_token(token)
assert verified_data is None, f"Should reject malformed token: {token}"
def test_verify_expired_token(self):
"""Test verifying an expired token."""
token_data = TokenData(
user_id="user123",
email="test@example.com",
is_admin=False,
)
# Create token that expires in 100 milliseconds (0.1 seconds)
token = create_access_token(token_data, expires_delta=timedelta(milliseconds=100))
# Wait for token to expire (wait longer than expiration - 200ms to be safe)
time.sleep(0.2)
# Token should be invalid now
verified_data = verify_token(token)
assert verified_data is None
def test_verify_token_with_modified_payload(self):
"""Test that tokens with modified payloads are rejected."""
token_data = TokenData(
user_id="user123",
email="test@example.com",
is_admin=False,
)
token = create_access_token(token_data)
# Try to modify the token by changing a character in the middle
# JWT tokens are base64 encoded, so changing any character should invalidate the signature
# Note: We change a character in the middle to avoid Base64 padding issues where
# the last character might not affect the decoded value
if len(token) > MIN_TOKEN_LENGTH_FOR_MODIFICATION:
mid = len(token) // 2
modified_token = token[:mid] + ("X" if token[mid] != "X" else "Y") + token[mid + 1 :]
verified_data = verify_token(modified_token)
assert verified_data is None
def test_verify_token_preserves_admin_status(self):
"""Test that admin status is correctly preserved through token lifecycle."""
# Test with regular user
token_data = TokenData(
user_id="user123",
email="user@example.com",
is_admin=False,
)
token = create_access_token(token_data)
verified = verify_token(token)
assert verified is not None
assert verified.is_admin is False
# Test with admin user
admin_token_data = TokenData(
user_id="admin123",
email="admin@example.com",
is_admin=True,
)
admin_token = create_access_token(admin_token_data)
admin_verified = verify_token(admin_token)
assert admin_verified is not None
assert admin_verified.is_admin is True
class TestTokenExpiration:
"""Tests for token expiration handling."""
def test_token_not_expired_immediately(self):
"""Test that freshly created token is not expired."""
token_data = TokenData(
user_id="user123",
email="test@example.com",
is_admin=False,
)
token = create_access_token(token_data, expires_delta=timedelta(hours=1))
verified_data = verify_token(token)
assert verified_data is not None
def test_token_with_long_expiration(self):
"""Test token with long expiration time."""
token_data = TokenData(
user_id="user123",
email="test@example.com",
is_admin=False,
)
# Create token that expires in 7 days
token = create_access_token(token_data, expires_delta=timedelta(days=7))
verified_data = verify_token(token)
assert verified_data is not None
assert verified_data.user_id == "user123"
def test_token_with_short_expiration_not_expired(self):
"""Test token with short but not yet expired time."""
token_data = TokenData(
user_id="user123",
email="test@example.com",
is_admin=False,
)
# Create token that expires in 1 second
token = create_access_token(token_data, expires_delta=timedelta(seconds=1))
# Immediately verify - should still be valid
verified_data = verify_token(token)
assert verified_data is not None
class TestTokenDataModel:
"""Tests for TokenData model."""
def test_token_data_creation(self):
"""Test creating TokenData instance."""
token_data = TokenData(
user_id="user123",
email="test@example.com",
is_admin=False,
)
assert token_data.user_id == "user123"
assert token_data.email == "test@example.com"
assert token_data.is_admin is False
def test_token_data_with_admin(self):
"""Test TokenData for admin user."""
token_data = TokenData(
user_id="admin123",
email="admin@example.com",
is_admin=True,
)
assert token_data.is_admin is True
def test_token_data_model_dump(self):
"""Test that TokenData can be serialized."""
token_data = TokenData(
user_id="user123",
email="test@example.com",
is_admin=False,
)
data_dict = token_data.model_dump()
assert isinstance(data_dict, dict)
assert data_dict["user_id"] == "user123"
assert data_dict["email"] == "test@example.com"
assert data_dict["is_admin"] is False
class TestTokenSecurity:
"""Tests for token security properties."""
def test_token_signature_verification(self):
"""Test that token signature is verified."""
token_data = TokenData(
user_id="user123",
email="test@example.com",
is_admin=False,
)
token = create_access_token(token_data)
# Token should verify correctly
assert verify_token(token) is not None
# Modified token should fail verification
if len(token) > MIN_TOKEN_LENGTH_FOR_MODIFICATION:
# Change a character in the signature part (last part of JWT)
parts = token.split(".")
if len(parts) == 3 and len(parts[2]) > MIN_SIGNATURE_LENGTH_FOR_MODIFICATION:
# Modify a character in the middle of the signature to avoid Base64 padding issues
# where the last few characters might not affect the decoded value
mid = len(parts[2]) // 2
modified_signature = parts[2][:mid] + ("X" if parts[2][mid] != "X" else "Y") + parts[2][mid + 1 :]
modified_token = f"{parts[0]}.{parts[1]}.{modified_signature}"
assert verify_token(modified_token) is None
def test_cannot_forge_admin_token(self):
"""Test that admin status cannot be forged by modifying token."""
token_data = TokenData(
user_id="user123",
email="test@example.com",
is_admin=False,
)
token = create_access_token(token_data)
# Any modification to the token should invalidate it
# This prevents attackers from changing is_admin=false to is_admin=true
parts = token.split(".")
if len(parts) == 3:
# Try to modify the payload
modified_payload = parts[1][:-1] + ("X" if parts[1][-1] != "X" else "Y")
modified_token = f"{parts[0]}.{modified_payload}.{parts[2]}"
verified_data = verify_token(modified_token)
# Modified token should be rejected
assert verified_data is None
def test_token_uses_strong_algorithm(self):
"""Test that token uses secure algorithm (HS256)."""
token_data = TokenData(
user_id="user123",
email="test@example.com",
is_admin=False,
)
token = create_access_token(token_data)
# JWT tokens have format: header.payload.signature
# Header contains algorithm information
import base64
import json
parts = token.split(".")
if len(parts) >= 1:
# Decode header (add padding if necessary)
header_b64 = parts[0]
# Add padding if necessary
padding = 4 - len(header_b64) % 4
if padding != 4:
header_b64 += "=" * padding
header = json.loads(base64.urlsafe_b64decode(header_b64))
# Should use HS256 algorithm
assert header.get("alg") == "HS256"
@@ -0,0 +1,398 @@
import os
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any
from zipfile import ZipFile
import pytest
from invokeai.app.services.board_records.board_records_common import BoardRecord, BoardRecordNotFoundException
from invokeai.app.services.bulk_download.bulk_download_common import BulkDownloadTargetException
from invokeai.app.services.bulk_download.bulk_download_default import BulkDownloadService
from invokeai.app.services.events.events_common import (
BulkDownloadCompleteEvent,
BulkDownloadErrorEvent,
BulkDownloadStartedEvent,
)
from invokeai.app.services.image_records.image_records_common import (
ImageCategory,
ImageRecordNotFoundException,
ResourceOrigin,
)
from invokeai.app.services.images.images_common import ImageDTO
from invokeai.app.services.invoker import Invoker
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
from tests.test_nodes import TestEventService
@pytest.fixture
def mock_image_dto() -> ImageDTO:
"""Create a mock ImageDTO."""
return ImageDTO(
image_name="mock_image.png",
board_id="12345",
image_url="None",
width=100,
height=100,
thumbnail_url="None",
image_origin=ResourceOrigin.INTERNAL,
image_category=ImageCategory.GENERAL,
created_at="None",
updated_at="None",
starred=False,
has_workflow=False,
is_intermediate=False,
)
@pytest.fixture(autouse=True)
def mock_temporary_directory(monkeypatch: Any, tmp_path: Path):
"""Mock the TemporaryDirectory class so that it uses the tmp_path fixture."""
class MockTemporaryDirectory(TemporaryDirectory):
def __init__(self):
super().__init__(dir=tmp_path)
self.name = tmp_path
def mock_TemporaryDirectory(*args, **kwargs):
return MockTemporaryDirectory()
monkeypatch.setattr(
"invokeai.app.services.bulk_download.bulk_download_default.TemporaryDirectory", mock_TemporaryDirectory
)
def test_get_path_when_file_exists(tmp_path: Path) -> None:
"""Test get_path when the file exists."""
bulk_download_service = BulkDownloadService()
# Create a directory at tmp_path/bulk_downloads
test_bulk_downloads_dir: Path = tmp_path / "bulk_downloads"
test_bulk_downloads_dir.mkdir(parents=True, exist_ok=True)
# Create a file at tmp_path/bulk_downloads/test.zip
test_file_path: Path = test_bulk_downloads_dir / "test.zip"
test_file_path.touch()
assert bulk_download_service.get_path("test.zip") == str(test_file_path)
def test_get_path_when_file_does_not_exist(tmp_path: Path) -> None:
"""Test get_path when the file does not exist."""
bulk_download_service = BulkDownloadService()
with pytest.raises(BulkDownloadTargetException):
bulk_download_service.get_path("test")
def test_bulk_downloads_dir_created_at_start(tmp_path: Path) -> None:
"""Test that the bulk_downloads directory is created at start."""
BulkDownloadService()
assert (tmp_path / "bulk_downloads").exists()
def test_handler_image_names(tmp_path: Path, monkeypatch: Any, mock_image_dto: ImageDTO, mock_invoker: Invoker):
"""Test that the handler creates the zip file correctly when given a list of image names."""
expected_zip_path, expected_image_path, mock_image_contents = prepare_handler_test(
tmp_path, monkeypatch, mock_image_dto, mock_invoker
)
bulk_download_service = BulkDownloadService()
bulk_download_service.start(mock_invoker)
bulk_download_service.handler([mock_image_dto.image_name], None, None)
assert_handler_success(
expected_zip_path, expected_image_path, mock_image_contents, tmp_path, mock_invoker.services.events
)
def test_generate_id(monkeypatch: Any):
"""Test that the generate_id method generates a unique id."""
bulk_download_service = BulkDownloadService()
monkeypatch.setattr("invokeai.app.services.bulk_download.bulk_download_default.uuid_string", lambda: "test")
assert bulk_download_service.generate_item_id(None) == "test"
def test_generate_id_with_board_id(monkeypatch: Any, mock_invoker: Invoker):
"""Test that the generate_id method generates a unique id with a board id."""
bulk_download_service = BulkDownloadService()
bulk_download_service.start(mock_invoker)
def mock_board_get(*args, **kwargs):
return BoardRecord(
board_id="12345",
board_name="test_board_name",
user_id="test_user",
created_at="None",
updated_at="None",
archived=False,
)
monkeypatch.setattr(mock_invoker.services.board_records, "get", mock_board_get)
monkeypatch.setattr("invokeai.app.services.bulk_download.bulk_download_default.uuid_string", lambda: "test")
assert bulk_download_service.generate_item_id("12345") == "test_board_name_test"
def test_generate_id_with_default_board_id(monkeypatch: Any):
"""Test that the generate_id method generates a unique id with a board id."""
bulk_download_service = BulkDownloadService()
monkeypatch.setattr("invokeai.app.services.bulk_download.bulk_download_default.uuid_string", lambda: "test")
assert bulk_download_service.generate_item_id("none") == "Uncategorized_test"
def test_handler_board_id(tmp_path: Path, monkeypatch: Any, mock_image_dto: ImageDTO, mock_invoker: Invoker):
"""Test that the handler creates the zip file correctly when given a board id."""
expected_zip_path, expected_image_path, mock_image_contents = prepare_handler_test(
tmp_path, monkeypatch, mock_image_dto, mock_invoker
)
def mock_board_get(*args, **kwargs):
return BoardRecord(
board_id="12345",
board_name="test_board_name",
user_id="test_user",
created_at="None",
updated_at="None",
archived=False,
)
monkeypatch.setattr(mock_invoker.services.board_records, "get", mock_board_get)
def mock_get_many(*args, **kwargs):
return OffsetPaginatedResults(limit=-1, total=1, offset=0, items=[mock_image_dto])
monkeypatch.setattr(mock_invoker.services.images, "get_many", mock_get_many)
bulk_download_service = BulkDownloadService()
bulk_download_service.start(mock_invoker)
bulk_download_service.handler([], "test", None)
assert_handler_success(
expected_zip_path, expected_image_path, mock_image_contents, tmp_path, mock_invoker.services.events
)
def test_handler_board_id_default(tmp_path: Path, monkeypatch: Any, mock_image_dto: ImageDTO, mock_invoker: Invoker):
"""Test that the handler creates the zip file correctly when given a board id."""
_, expected_image_path, mock_image_contents = prepare_handler_test(
tmp_path, monkeypatch, mock_image_dto, mock_invoker
)
def mock_get_many(*args, **kwargs):
return OffsetPaginatedResults(limit=-1, total=1, offset=0, items=[mock_image_dto])
monkeypatch.setattr(mock_invoker.services.images, "get_many", mock_get_many)
bulk_download_service = BulkDownloadService()
bulk_download_service.start(mock_invoker)
bulk_download_service.handler([], "none", None)
expected_zip_path: Path = tmp_path / "bulk_downloads" / "test.zip"
assert_handler_success(
expected_zip_path, expected_image_path, mock_image_contents, tmp_path, mock_invoker.services.events
)
def test_handler_bulk_download_item_id_given(
tmp_path: Path, monkeypatch: Any, mock_image_dto: ImageDTO, mock_invoker: Invoker
):
"""Test that the handler creates the zip file correctly when given a pregenerated bulk download item id."""
_, expected_image_path, mock_image_contents = prepare_handler_test(
tmp_path, monkeypatch, mock_image_dto, mock_invoker
)
def mock_get_many(*args, **kwargs):
return OffsetPaginatedResults(limit=-1, total=1, offset=0, items=[mock_image_dto])
monkeypatch.setattr(mock_invoker.services.images, "get_many", mock_get_many)
bulk_download_service = BulkDownloadService()
bulk_download_service.start(mock_invoker)
bulk_download_service.handler([mock_image_dto.image_name], None, "test_id")
expected_zip_path: Path = tmp_path / "bulk_downloads" / "test_id.zip"
assert_handler_success(
expected_zip_path, expected_image_path, mock_image_contents, tmp_path, mock_invoker.services.events
)
def prepare_handler_test(tmp_path: Path, monkeypatch: Any, mock_image_dto: ImageDTO, mock_invoker: Invoker):
"""Prepare the test for the handler tests."""
def mock_uuid_string():
return "test"
# You have to patch the function within the module it's being imported into. This is strange, but it works.
# See http://www.gregreda.com/2021/06/28/mocking-imported-module-function-python/
monkeypatch.setattr("invokeai.app.services.bulk_download.bulk_download_default.uuid_string", mock_uuid_string)
expected_zip_path: Path = tmp_path / "bulk_downloads" / "test.zip"
expected_image_path: Path = (
tmp_path / "bulk_downloads" / mock_image_dto.image_category.value / mock_image_dto.image_name
)
# Mock the get_dto method so that when the image dto needs to be retrieved it is returned
def mock_get_dto(*args, **kwargs):
return mock_image_dto
monkeypatch.setattr(mock_invoker.services.images, "get_dto", mock_get_dto)
# This is used when preparing all images for a given board
def mock_get_all_board_image_names_for_board(*args, **kwargs):
return [mock_image_dto.image_name]
monkeypatch.setattr(
mock_invoker.services.board_image_records,
"get_all_board_image_names_for_board",
mock_get_all_board_image_names_for_board,
)
# Create a mock image file so that the contents of the zip file are not empty
mock_image_path: Path = tmp_path / mock_image_dto.image_name
mock_image_contents: str = "Totally an image"
mock_image_path.write_text(mock_image_contents)
def mock_get_path(*args, **kwargs):
return str(mock_image_path)
monkeypatch.setattr(mock_invoker.services.images, "get_path", mock_get_path)
return expected_zip_path, expected_image_path, mock_image_contents
def assert_handler_success(
expected_zip_path: Path,
expected_image_path: Path,
mock_image_contents: str,
tmp_path: Path,
event_bus: TestEventService,
):
"""Assert that the handler was successful."""
# Check that the zip file was created
assert expected_zip_path.exists()
assert expected_zip_path.is_file()
assert expected_zip_path.stat().st_size > 0
# Check that the zip contents are expected
with ZipFile(expected_zip_path, "r") as zip_file:
zip_file.extractall(tmp_path / "bulk_downloads")
assert expected_image_path.exists()
assert expected_image_path.is_file()
assert expected_image_path.stat().st_size > 0
assert expected_image_path.read_text() == mock_image_contents
# Check that the correct events were emitted
assert len(event_bus.events) == 2
assert isinstance(event_bus.events[0], BulkDownloadStartedEvent)
assert isinstance(event_bus.events[1], BulkDownloadCompleteEvent)
assert event_bus.events[1].bulk_download_item_name == os.path.basename(expected_zip_path)
def test_handler_on_image_not_found(tmp_path: Path, monkeypatch: Any, mock_image_dto: ImageDTO, mock_invoker: Invoker):
"""Test that the handler emits an error event when the image is not found."""
exception: Exception = ImageRecordNotFoundException("Image not found")
def mock_get_dto(*args, **kwargs):
raise exception
monkeypatch.setattr(mock_invoker.services.images, "get_dto", mock_get_dto)
execute_handler_test_on_error(tmp_path, monkeypatch, mock_image_dto, mock_invoker, exception)
def test_handler_on_board_not_found(tmp_path: Path, monkeypatch: Any, mock_image_dto: ImageDTO, mock_invoker: Invoker):
"""Test that the handler emits an error event when the image is not found."""
exception: Exception = BoardRecordNotFoundException("Image not found")
def mock_get_board_name(*args, **kwargs):
raise exception
monkeypatch.setattr(mock_invoker.services.images, "get_dto", mock_get_board_name)
execute_handler_test_on_error(tmp_path, monkeypatch, mock_image_dto, mock_invoker, exception)
def test_handler_on_generic_exception(
tmp_path: Path, monkeypatch: Any, mock_image_dto: ImageDTO, mock_invoker: Invoker
):
"""Test that the handler emits an error event when the image is not found."""
exception: Exception = Exception("Generic exception")
def mock_get_board_name(*args, **kwargs):
raise exception
monkeypatch.setattr(mock_invoker.services.images, "get_dto", mock_get_board_name)
with pytest.raises(Exception): # noqa: B017
execute_handler_test_on_error(tmp_path, monkeypatch, mock_image_dto, mock_invoker, exception)
event_bus: TestEventService = mock_invoker.services.events
assert len(event_bus.events) == 2
assert isinstance(event_bus.events[0], BulkDownloadStartedEvent)
assert isinstance(event_bus.events[1], BulkDownloadErrorEvent)
assert event_bus.events[1].error == exception.__str__()
def execute_handler_test_on_error(
tmp_path: Path, monkeypatch: Any, mock_image_dto: ImageDTO, mock_invoker: Invoker, error: Exception
):
bulk_download_service = BulkDownloadService()
bulk_download_service.start(mock_invoker)
bulk_download_service.handler([mock_image_dto.image_name], None, None)
event_bus: TestEventService = mock_invoker.services.events
assert len(event_bus.events) == 2
assert isinstance(event_bus.events[0], BulkDownloadStartedEvent)
assert isinstance(event_bus.events[1], BulkDownloadErrorEvent)
assert event_bus.events[1].error == error.__str__()
def test_delete(tmp_path: Path):
"""Test that the delete method removes the bulk download file."""
bulk_download_service = BulkDownloadService()
mock_file: Path = tmp_path / "bulk_downloads" / "test.zip"
mock_file.write_text("contents")
bulk_download_service.delete("test.zip")
assert (tmp_path / "bulk_downloads").exists()
assert len(os.listdir(tmp_path / "bulk_downloads")) == 0
def test_stop(tmp_path: Path):
"""Test that the stop method removes the bulk download file and not any directories."""
bulk_download_service = BulkDownloadService()
mock_file: Path = tmp_path / "bulk_downloads" / "test.zip"
mock_file.write_text("contents")
mock_dir: Path = tmp_path / "bulk_downloads" / "test"
mock_dir.mkdir(parents=True, exist_ok=True)
bulk_download_service.stop()
assert not (tmp_path / "bulk_downloads").exists()
@@ -0,0 +1,357 @@
"""Test the queued download facility"""
import re
import time
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Generator, Optional
import pytest
from pydantic.networks import AnyHttpUrl
from requests.sessions import Session
from requests_testadapter import TestAdapter
from invokeai.app.services.config import get_config
from invokeai.app.services.config.config_default import URLRegexTokenPair
from invokeai.app.services.download import DownloadJob, DownloadJobStatus, DownloadQueueService, MultiFileDownloadJob
from invokeai.app.services.events.events_common import (
DownloadCancelledEvent,
DownloadCompleteEvent,
DownloadErrorEvent,
DownloadProgressEvent,
DownloadStartedEvent,
)
from invokeai.backend.model_manager.metadata import HuggingFaceMetadataFetch, ModelMetadataWithFiles, RemoteModelFile
from tests.test_nodes import TestEventService
# Prevent pytest deprecation warnings
TestAdapter.__test__ = False
@pytest.mark.timeout(timeout=10, method="thread")
def test_basic_queue_download(tmp_path: Path, mm2_session: Session) -> None:
events = set()
def event_handler(job: DownloadJob, excp: Optional[Exception] = None) -> None:
events.add(job.status)
queue = DownloadQueueService(
requests_session=mm2_session,
)
queue.start()
job = queue.download(
source=AnyHttpUrl("http://www.civitai.com/models/12345"),
dest=tmp_path,
on_start=event_handler,
on_progress=event_handler,
on_complete=event_handler,
on_error=event_handler,
)
assert isinstance(job, DownloadJob), "expected the job to be of type DownloadJobBase"
assert isinstance(job.id, int), "expected the job id to be numeric"
queue.join()
assert job.status == DownloadJobStatus("completed"), "expected job status to be completed"
assert job.download_path == tmp_path / "mock12345.safetensors"
assert Path(tmp_path, "mock12345.safetensors").exists(), f"expected {tmp_path}/mock12345.safetensors to exist"
assert events == {DownloadJobStatus.RUNNING, DownloadJobStatus.COMPLETED}
queue.stop()
@pytest.mark.timeout(timeout=10, method="thread")
def test_errors(tmp_path: Path, mm2_session: Session) -> None:
queue = DownloadQueueService(
requests_session=mm2_session,
)
queue.start()
for bad_url in ["http://www.civitai.com/models/broken", "http://www.civitai.com/models/missing"]:
queue.download(AnyHttpUrl(bad_url), dest=tmp_path)
queue.join()
jobs = queue.list_jobs()
print(jobs)
assert len(jobs) == 2
jobs_dict = {str(x.source): x for x in jobs}
assert jobs_dict["http://www.civitai.com/models/broken"].status == DownloadJobStatus.ERROR
assert jobs_dict["http://www.civitai.com/models/broken"].error_type == "HTTPError(NOT FOUND)"
assert jobs_dict["http://www.civitai.com/models/missing"].status == DownloadJobStatus.COMPLETED
assert jobs_dict["http://www.civitai.com/models/missing"].total_bytes == 0
queue.stop()
@pytest.mark.timeout(timeout=10, method="thread")
def test_event_bus(tmp_path: Path, mm2_session: Session) -> None:
event_bus = TestEventService()
queue = DownloadQueueService(requests_session=mm2_session, event_bus=event_bus)
queue.start()
queue.download(
source=AnyHttpUrl("http://www.civitai.com/models/12345"),
dest=tmp_path,
)
queue.join()
events = event_bus.events
assert len(events) == 3
assert isinstance(events[0], DownloadStartedEvent)
assert isinstance(events[1], DownloadProgressEvent)
assert isinstance(events[2], DownloadCompleteEvent)
assert events[0].timestamp <= events[1].timestamp
assert events[1].timestamp <= events[2].timestamp
assert events[1].total_bytes > 0
assert events[1].current_bytes <= events[1].total_bytes
assert events[2].total_bytes == 32029
# test a failure
event_bus.events = [] # reset our accumulator
queue.download(source=AnyHttpUrl("http://www.civitai.com/models/broken"), dest=tmp_path)
queue.join()
events = event_bus.events
print("\n".join([x.model_dump_json() for x in events]))
assert len(events) == 1
assert isinstance(events[0], DownloadErrorEvent)
assert events[0].error_type == "HTTPError(NOT FOUND)"
assert events[0].error is not None
assert re.search(r"requests.exceptions.HTTPError: NOT FOUND", events[0].error)
queue.stop()
@pytest.mark.timeout(timeout=10, method="thread")
def test_broken_callbacks(tmp_path: Path, mm2_session: Session, capsys) -> None:
queue = DownloadQueueService(
requests_session=mm2_session,
)
queue.start()
callback_ran = False
def broken_callback(job: DownloadJob) -> None:
nonlocal callback_ran
callback_ran = True
print(1 / 0) # deliberate error here
job = queue.download(
source=AnyHttpUrl("http://www.civitai.com/models/12345"),
dest=tmp_path,
on_progress=broken_callback,
)
queue.join()
assert job.status == DownloadJobStatus.COMPLETED # should complete even though the callback is borked
assert Path(tmp_path, "mock12345.safetensors").exists()
assert callback_ran
# LS: The pytest capsys fixture does not seem to be working. I can see the
# correct stderr message in the pytest log, but it is not appearing in
# capsys.readouterr().
# captured = capsys.readouterr()
# assert re.search("division by zero", captured.err)
queue.stop()
@pytest.mark.timeout(timeout=10, method="thread")
def test_cancel(tmp_path: Path, mm2_session: Session) -> None:
event_bus = TestEventService()
queue = DownloadQueueService(requests_session=mm2_session, event_bus=event_bus)
queue.start()
cancelled = False
def slow_callback(job: DownloadJob) -> None:
time.sleep(2)
def cancelled_callback(job: DownloadJob) -> None:
nonlocal cancelled
cancelled = True
job = queue.download(
source=AnyHttpUrl("http://www.civitai.com/models/12345"),
dest=tmp_path,
on_start=slow_callback,
on_cancelled=cancelled_callback,
)
queue.cancel_job(job)
queue.join()
assert job.status == DownloadJobStatus.CANCELLED
assert cancelled
events = event_bus.events
assert isinstance(events[-1], DownloadCancelledEvent)
assert events[-1].source == "http://www.civitai.com/models/12345"
queue.stop()
@pytest.mark.timeout(timeout=10, method="thread")
def test_multifile_download(tmp_path: Path, mm2_session: Session) -> None:
fetcher = HuggingFaceMetadataFetch(mm2_session)
metadata = fetcher.from_id("stabilityai/sdxl-turbo")
assert isinstance(metadata, ModelMetadataWithFiles)
events = set()
def event_handler(job: DownloadJob | MultiFileDownloadJob, excp: Optional[Exception] = None) -> None:
events.add(job.status)
queue = DownloadQueueService(
requests_session=mm2_session,
)
queue.start()
job = queue.multifile_download(
parts=metadata.download_urls(session=mm2_session),
dest=tmp_path,
on_start=event_handler,
on_progress=event_handler,
on_complete=event_handler,
on_error=event_handler,
)
assert isinstance(job, MultiFileDownloadJob), "expected the job to be of type MultiFileDownloadJobBase"
queue.join()
assert job.status == DownloadJobStatus("completed"), "expected job status to be completed"
assert job.bytes > 0, "expected download bytes to be positive"
assert job.bytes == job.total_bytes, "expected download bytes to equal total bytes"
assert job.download_path == tmp_path / "sdxl-turbo"
assert Path(tmp_path, "sdxl-turbo/model_index.json").exists(), (
f"expected {tmp_path}/sdxl-turbo/model_inded.json to exist"
)
assert Path(tmp_path, "sdxl-turbo/text_encoder/config.json").exists(), (
f"expected {tmp_path}/sdxl-turbo/text_encoder/config.json to exist"
)
assert events == {DownloadJobStatus.RUNNING, DownloadJobStatus.COMPLETED}
queue.stop()
@pytest.mark.timeout(timeout=10, method="thread")
def test_multifile_download_error(tmp_path: Path, mm2_session: Session) -> None:
fetcher = HuggingFaceMetadataFetch(mm2_session)
metadata = fetcher.from_id("stabilityai/sdxl-turbo")
assert isinstance(metadata, ModelMetadataWithFiles)
events = set()
def event_handler(job: DownloadJob | MultiFileDownloadJob, excp: Optional[Exception] = None) -> None:
events.add(job.status)
queue = DownloadQueueService(
requests_session=mm2_session,
)
queue.start()
files = metadata.download_urls(session=mm2_session)
# this will give a 404 error
files.append(RemoteModelFile(url="https://test.com/missing_model.safetensors", path=Path("sdxl-turbo/broken")))
job = queue.multifile_download(
parts=files,
dest=tmp_path,
on_start=event_handler,
on_progress=event_handler,
on_complete=event_handler,
on_error=event_handler,
)
queue.join()
assert job.status == DownloadJobStatus("error"), "expected job status to be errored"
assert job.error_type is not None
assert "HTTPError(NOT FOUND)" in job.error_type
assert DownloadJobStatus.ERROR in events
queue.stop()
@pytest.mark.timeout(timeout=10, method="thread")
def test_multifile_cancel(tmp_path: Path, mm2_session: Session, monkeypatch: Any) -> None:
event_bus = TestEventService()
queue = DownloadQueueService(requests_session=mm2_session, event_bus=event_bus)
queue.start()
cancelled = False
def cancelled_callback(job: DownloadJob) -> None:
nonlocal cancelled
cancelled = True
fetcher = HuggingFaceMetadataFetch(mm2_session)
metadata = fetcher.from_id("stabilityai/sdxl-turbo")
assert isinstance(metadata, ModelMetadataWithFiles)
job = queue.multifile_download(
parts=metadata.download_urls(session=mm2_session),
dest=tmp_path,
on_cancelled=cancelled_callback,
)
queue.cancel_job(job)
queue.join()
assert job.status == DownloadJobStatus.CANCELLED
assert cancelled
events = event_bus.events
assert DownloadCancelledEvent in [type(x) for x in events]
queue.stop()
def test_multifile_onefile(tmp_path: Path, mm2_session: Session) -> None:
queue = DownloadQueueService(
requests_session=mm2_session,
)
queue.start()
job = queue.multifile_download(
parts=[
RemoteModelFile(url=AnyHttpUrl("http://www.civitai.com/models/12345"), path=Path("mock12345.safetensors"))
],
dest=tmp_path,
)
assert isinstance(job, MultiFileDownloadJob), "expected the job to be of type MultiFileDownloadJobBase"
queue.join()
assert job.status == DownloadJobStatus("completed"), "expected job status to be completed"
assert job.bytes > 0, "expected download bytes to be positive"
assert job.bytes == job.total_bytes, "expected download bytes to equal total bytes"
assert job.download_path == tmp_path / "mock12345.safetensors"
assert Path(tmp_path, "mock12345.safetensors").exists(), f"expected {tmp_path}/mock12345.safetensors to exist"
queue.stop()
def test_multifile_no_rel_paths(tmp_path: Path, mm2_session: Session) -> None:
queue = DownloadQueueService(
requests_session=mm2_session,
)
with pytest.raises(AssertionError) as error:
queue.multifile_download(
parts=[RemoteModelFile(url=AnyHttpUrl("http://www.civitai.com/models/12345"), path=Path("/etc/passwd"))],
dest=tmp_path,
)
assert str(error.value) == "only relative download paths accepted"
@contextmanager
def clear_config() -> Generator[None, None, None]:
try:
yield None
finally:
get_config.cache_clear()
def test_tokens(tmp_path: Path, mm2_session: Session):
with clear_config():
config = get_config()
config.remote_api_tokens = [URLRegexTokenPair(url_regex="civitai", token="cv_12345")]
queue = DownloadQueueService(requests_session=mm2_session)
queue.start()
# this one has an access token assigned
job1 = queue.download(
source=AnyHttpUrl("http://www.civitai.com/models/12345"),
dest=tmp_path,
)
# this one doesn't
job2 = queue.download(
source=AnyHttpUrl(
"http://www.huggingface.co/foo.txt",
),
dest=tmp_path,
)
queue.join()
# this token is defined in the temporary root invokeai.yaml
# see tests/backend/model_manager/data/invokeai_root/invokeai.yaml
assert job1.access_token == "cv_12345"
assert job2.access_token is None
queue.stop()
@@ -0,0 +1,311 @@
import io
import logging
from typing import Any, Iterator
import pytest
from PIL import Image
from invokeai.app.services.config.config_default import InvokeAIAppConfig
from invokeai.app.services.external_generation.errors import ExternalProviderRequestError
from invokeai.app.services.external_generation.external_generation_common import (
ExternalGenerationRequest,
ExternalReferenceImage,
)
from invokeai.app.services.external_generation.image_utils import encode_image_base64
from invokeai.app.services.external_generation.providers import alibabacloud as alibabacloud_module
from invokeai.app.services.external_generation.providers.alibabacloud import AlibabaCloudProvider
from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig, ExternalModelCapabilities
class DummyResponse:
def __init__(
self,
ok: bool,
status_code: int = 200,
json_data: dict | None = None,
text: str = "",
content: bytes = b"",
headers: dict[str, str] | None = None,
) -> None:
self.ok = ok
self.status_code = status_code
self._json_data = json_data or {}
self.text = text
self.content = content
self.headers: dict[str, str] = headers or {}
def json(self) -> dict:
return self._json_data
def iter_content(self, chunk_size: int = 65536) -> Iterator[bytes]:
for i in range(0, len(self.content), chunk_size):
yield self.content[i : i + chunk_size]
def __enter__(self) -> "DummyResponse":
return self
def __exit__(self, *_args: Any) -> None:
return None
def _make_image(color: str = "blue") -> Image.Image:
return Image.new("RGB", (16, 16), color=color)
def _png_bytes(image: Image.Image) -> bytes:
buf = io.BytesIO()
image.save(buf, format="PNG")
return buf.getvalue()
def _build_model(provider_model_id: str) -> ExternalApiModelConfig:
return ExternalApiModelConfig(
key=f"alibabacloud_{provider_model_id}",
name=provider_model_id,
provider_id="alibabacloud",
provider_model_id=provider_model_id,
capabilities=ExternalModelCapabilities(
modes=["txt2img"],
supports_reference_images=True,
supports_seed=True,
),
)
def _build_request(
model: ExternalApiModelConfig,
reference_images: list[ExternalReferenceImage] | None = None,
) -> ExternalGenerationRequest:
return ExternalGenerationRequest(
model=model,
mode="txt2img", # type: ignore[arg-type]
prompt="a cat",
seed=42,
num_images=1,
width=1024,
height=1024,
image_size=None,
init_image=None,
mask_image=None,
reference_images=reference_images or [],
metadata=None,
)
def _provider() -> AlibabaCloudProvider:
config = InvokeAIAppConfig(external_alibabacloud_api_key="test-key")
return AlibabaCloudProvider(config, logging.getLogger("test"))
def test_unknown_model_id_raises(monkeypatch: pytest.MonkeyPatch) -> None:
provider = _provider()
request = _build_request(_build_model("not-a-real-model"))
def fail_post(*_args: Any, **_kwargs: Any) -> DummyResponse: # pragma: no cover - should not be called
raise AssertionError("network must not be touched for unknown model")
monkeypatch.setattr("requests.post", fail_post)
with pytest.raises(ExternalProviderRequestError, match="Unknown DashScope model_id"):
provider.generate(request)
def test_sync_routes_qwen_edit_max_with_reference_images(monkeypatch: pytest.MonkeyPatch) -> None:
provider = _provider()
ref = _make_image("red")
request = _build_request(
_build_model("qwen-image-edit-max"),
reference_images=[ExternalReferenceImage(image=ref)],
)
captured: dict[str, Any] = {}
image_url = "https://example.invalid/result.png"
image_bytes = _png_bytes(_make_image("green"))
def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyResponse:
captured["url"] = url
captured["json"] = json
return DummyResponse(
ok=True,
json_data={
"request_id": "req-1",
"output": {
"choices": [
{"message": {"content": [{"image": image_url}]}},
]
},
},
)
def fake_get(url: str, timeout: int, stream: bool = False) -> DummyResponse:
assert url == image_url
return DummyResponse(
ok=True,
content=image_bytes,
headers={"Content-Length": str(len(image_bytes))},
)
monkeypatch.setattr("requests.post", fake_post)
monkeypatch.setattr("requests.get", fake_get)
result = provider.generate(request)
assert "multimodal-generation" in captured["url"]
payload = captured["json"]
messages = payload["input"]["messages"]
content = messages[0]["content"]
# Reference image first, then prompt text — and no init_image entry.
assert content[0]["image"].startswith("data:image/png;base64,")
assert content[0]["image"].endswith(encode_image_base64(ref))
assert content[1] == {"text": request.prompt}
assert len(content) == 2
assert payload["model"] == "qwen-image-edit-max"
assert payload["parameters"]["seed"] == request.seed
assert result.provider_request_id == "req-1"
assert len(result.images) == 1
def test_sync_error_response_raises(monkeypatch: pytest.MonkeyPatch) -> None:
provider = _provider()
request = _build_request(_build_model("qwen-image-2.0-pro"))
def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyResponse:
return DummyResponse(ok=False, status_code=400, text="bad request")
monkeypatch.setattr("requests.post", fake_post)
with pytest.raises(ExternalProviderRequestError, match="DashScope request failed"):
provider.generate(request)
def test_sync_retries_on_429_and_succeeds(monkeypatch: pytest.MonkeyPatch) -> None:
provider = _provider()
request = _build_request(_build_model("qwen-image-2.0-pro"))
image_bytes = _png_bytes(_make_image("yellow"))
image_url = "https://example.invalid/r.png"
calls = {"n": 0}
def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyResponse:
calls["n"] += 1
if calls["n"] == 1:
return DummyResponse(ok=False, status_code=429, text="rate limited", headers={"Retry-After": "0"})
return DummyResponse(
ok=True,
json_data={
"request_id": "req-2",
"output": {"choices": [{"message": {"content": [{"image": image_url}]}}]},
},
)
def fake_get(url: str, timeout: int, stream: bool = False) -> DummyResponse:
return DummyResponse(ok=True, content=image_bytes, headers={"Content-Length": str(len(image_bytes))})
monkeypatch.setattr("requests.post", fake_post)
monkeypatch.setattr("requests.get", fake_get)
monkeypatch.setattr("time.sleep", lambda _s: None)
result = provider.generate(request)
assert calls["n"] == 2
assert len(result.images) == 1
def test_async_parser_does_not_double_count(monkeypatch: pytest.MonkeyPatch) -> None:
"""A result with both `url` and `b64_image` must yield one image, not two."""
provider = _provider()
request = _build_request(_build_model("qwen-image-2.0-pro"))
image_bytes = _png_bytes(_make_image("magenta"))
image_url = "https://example.invalid/x.png"
def fake_get(url: str, timeout: int, stream: bool = False) -> DummyResponse:
return DummyResponse(ok=True, content=image_bytes, headers={"Content-Length": str(len(image_bytes))})
monkeypatch.setattr("requests.get", fake_get)
output: dict[str, Any] = {
"results": [
{
"url": image_url,
"b64_image": encode_image_base64(_make_image("cyan")),
}
]
}
result = provider._parse_async_response(output, request, request_id="rid")
assert len(result.images) == 1
def test_async_parser_accepts_b64_only(monkeypatch: pytest.MonkeyPatch) -> None:
provider = _provider()
request = _build_request(_build_model("qwen-image-2.0-pro"))
output: dict[str, Any] = {
"results": [
{"b64_image": encode_image_base64(_make_image("cyan"))},
]
}
result = provider._parse_async_response(output, request, request_id="rid")
assert len(result.images) == 1
def test_download_image_size_cap(monkeypatch: pytest.MonkeyPatch) -> None:
provider = _provider()
too_big = alibabacloud_module._DOWNLOAD_MAX_BYTES + 1
def fake_get(url: str, timeout: int, stream: bool = False) -> DummyResponse:
return DummyResponse(
ok=True,
content=b"\x00" * 16, # body itself is small; we trip the Content-Length check first
headers={"Content-Length": str(too_big)},
)
monkeypatch.setattr("requests.get", fake_get)
with pytest.raises(ExternalProviderRequestError, match="exceeds"):
provider._download_image("https://example.invalid/big.png")
def test_poll_task_first_call_no_initial_sleep(monkeypatch: pytest.MonkeyPatch) -> None:
"""First poll must not be preceded by a sleep — fast tasks should not pay the poll interval."""
provider = _provider()
request = _build_request(_build_model("qwen-image-2.0-pro"))
image_bytes = _png_bytes(_make_image("teal"))
image_url = "https://example.invalid/y.png"
sleeps: list[float] = []
def fake_sleep(seconds: float) -> None:
sleeps.append(seconds)
def fake_get(url: str, headers: dict, timeout: int) -> DummyResponse:
return DummyResponse(
ok=True,
json_data={
"output": {
"task_status": "SUCCEEDED",
"results": [{"url": image_url}],
}
},
)
def fake_download_get(url: str, timeout: int, stream: bool = False) -> DummyResponse:
return DummyResponse(ok=True, content=image_bytes, headers={"Content-Length": str(len(image_bytes))})
# Single requests.get is shared by polling (with headers kwarg) and download (no kwarg).
def dispatch_get(*args: Any, **kwargs: Any) -> DummyResponse:
if "headers" in kwargs and "task" in args[0]:
return fake_get(*args, **kwargs)
return fake_download_get(*args, **kwargs)
monkeypatch.setattr("requests.get", dispatch_get)
monkeypatch.setattr("time.sleep", fake_sleep)
result = provider._poll_task(
base_url="https://dashscope.invalid",
headers={"Authorization": "Bearer test", "Content-Type": "application/json"},
task_id="task-xyz",
request=request,
request_id="rid",
)
assert len(result.images) == 1
# No sleep should have been recorded — task succeeded on the first poll.
assert sleeps == []
@@ -0,0 +1,277 @@
import logging
import pytest
from PIL import Image
from invokeai.app.services.config.config_default import InvokeAIAppConfig
from invokeai.app.services.external_generation.errors import (
ExternalProviderCapabilityError,
ExternalProviderNotConfiguredError,
ExternalProviderNotFoundError,
)
from invokeai.app.services.external_generation.external_generation_base import ExternalProvider
from invokeai.app.services.external_generation.external_generation_common import (
ExternalGeneratedImage,
ExternalGenerationRequest,
ExternalGenerationResult,
ExternalReferenceImage,
)
from invokeai.app.services.external_generation.external_generation_default import ExternalGenerationService
from invokeai.backend.model_manager.configs.external_api import (
ExternalApiModelConfig,
ExternalImageSize,
ExternalModelCapabilities,
)
class DummyProvider(ExternalProvider):
def __init__(self, provider_id: str, configured: bool, result: ExternalGenerationResult | None = None) -> None:
super().__init__(InvokeAIAppConfig(), logging.getLogger("test"))
self.provider_id = provider_id
self._configured = configured
self._result = result
self.last_request: ExternalGenerationRequest | None = None
def is_configured(self) -> bool:
return self._configured
def generate(self, request: ExternalGenerationRequest) -> ExternalGenerationResult:
self.last_request = request
assert self._result is not None
return self._result
def _build_model(capabilities: ExternalModelCapabilities) -> ExternalApiModelConfig:
return ExternalApiModelConfig(
key="external_test",
name="External Test",
provider_id="openai",
provider_model_id="gpt-image-1",
capabilities=capabilities,
)
def _build_request(
*,
model: ExternalApiModelConfig,
mode: str = "txt2img",
seed: int | None = None,
num_images: int = 1,
width: int = 64,
height: int = 64,
init_image: Image.Image | None = None,
mask_image: Image.Image | None = None,
reference_images: list[ExternalReferenceImage] | None = None,
) -> ExternalGenerationRequest:
return ExternalGenerationRequest(
model=model,
mode=mode, # type: ignore[arg-type]
prompt="A test prompt",
seed=seed,
num_images=num_images,
width=width,
height=height,
image_size=None,
init_image=init_image,
mask_image=mask_image,
reference_images=reference_images or [],
metadata=None,
)
def _make_image() -> Image.Image:
return Image.new("RGB", (64, 64), color="black")
def test_generate_requires_registered_provider() -> None:
model = _build_model(ExternalModelCapabilities(modes=["txt2img"]))
request = _build_request(model=model)
service = ExternalGenerationService({}, logging.getLogger("test"))
with pytest.raises(ExternalProviderNotFoundError):
service.generate(request)
def test_generate_requires_configured_provider() -> None:
model = _build_model(ExternalModelCapabilities(modes=["txt2img"]))
request = _build_request(model=model)
provider = DummyProvider("openai", configured=False)
service = ExternalGenerationService({"openai": provider}, logging.getLogger("test"))
with pytest.raises(ExternalProviderNotConfiguredError):
service.generate(request)
def test_generate_validates_mode_support() -> None:
model = _build_model(ExternalModelCapabilities(modes=["txt2img"]))
request = _build_request(model=model, mode="img2img", init_image=_make_image())
provider = DummyProvider("openai", configured=True, result=ExternalGenerationResult(images=[]))
service = ExternalGenerationService({"openai": provider}, logging.getLogger("test"))
with pytest.raises(ExternalProviderCapabilityError, match="Mode 'img2img'"):
service.generate(request)
def test_generate_requires_init_image_for_img2img() -> None:
model = _build_model(ExternalModelCapabilities(modes=["img2img"]))
request = _build_request(model=model, mode="img2img")
provider = DummyProvider("openai", configured=True, result=ExternalGenerationResult(images=[]))
service = ExternalGenerationService({"openai": provider}, logging.getLogger("test"))
with pytest.raises(ExternalProviderCapabilityError, match="requires an init image"):
service.generate(request)
def test_generate_requires_mask_for_inpaint() -> None:
model = _build_model(ExternalModelCapabilities(modes=["inpaint"]))
request = _build_request(model=model, mode="inpaint", init_image=_make_image())
provider = DummyProvider("openai", configured=True, result=ExternalGenerationResult(images=[]))
service = ExternalGenerationService({"openai": provider}, logging.getLogger("test"))
with pytest.raises(ExternalProviderCapabilityError, match="requires a mask"):
service.generate(request)
def test_generate_validates_reference_images() -> None:
model = _build_model(ExternalModelCapabilities(modes=["txt2img"], supports_reference_images=False))
request = _build_request(
model=model,
reference_images=[ExternalReferenceImage(image=_make_image())],
)
provider = DummyProvider("openai", configured=True, result=ExternalGenerationResult(images=[]))
service = ExternalGenerationService({"openai": provider}, logging.getLogger("test"))
with pytest.raises(ExternalProviderCapabilityError, match="Reference images"):
service.generate(request)
def test_generate_validates_limits() -> None:
model = _build_model(
ExternalModelCapabilities(
modes=["txt2img"],
supports_reference_images=True,
max_reference_images=1,
max_images_per_request=1,
)
)
request = _build_request(
model=model,
num_images=2,
reference_images=[
ExternalReferenceImage(image=_make_image()),
ExternalReferenceImage(image=_make_image()),
],
)
provider = DummyProvider("openai", configured=True, result=ExternalGenerationResult(images=[]))
service = ExternalGenerationService({"openai": provider}, logging.getLogger("test"))
with pytest.raises(ExternalProviderCapabilityError, match="supports at most"):
service.generate(request)
def test_generate_validates_allowed_aspect_ratios() -> None:
model = _build_model(
ExternalModelCapabilities(
modes=["txt2img"],
allowed_aspect_ratios=["1:1", "16:9"],
aspect_ratio_sizes={
"1:1": ExternalImageSize(width=1024, height=1024),
"16:9": ExternalImageSize(width=1344, height=768),
},
)
)
request = _build_request(model=model)
provider = DummyProvider("openai", configured=True, result=ExternalGenerationResult(images=[]))
service = ExternalGenerationService({"openai": provider}, logging.getLogger("test"))
response = service.generate(request)
assert response.images == []
assert provider.last_request is not None
assert provider.last_request.width == 1024
assert provider.last_request.height == 1024
def test_generate_validates_allowed_aspect_ratios_with_bucket_sizes() -> None:
model = _build_model(
ExternalModelCapabilities(
modes=["txt2img"],
allowed_aspect_ratios=["1:1", "16:9"],
aspect_ratio_sizes={
"1:1": ExternalImageSize(width=1024, height=1024),
"16:9": ExternalImageSize(width=1344, height=768),
},
)
)
request = _build_request(model=model, width=160, height=90)
provider = DummyProvider("openai", configured=True, result=ExternalGenerationResult(images=[]))
service = ExternalGenerationService({"openai": provider}, logging.getLogger("test"))
response = service.generate(request)
assert response.images == []
assert provider.last_request is not None
assert provider.last_request.width == 1344
assert provider.last_request.height == 768
def test_generate_happy_path() -> None:
model = _build_model(ExternalModelCapabilities(modes=["txt2img"], supports_seed=True))
request = _build_request(model=model, seed=42)
result = ExternalGenerationResult(images=[ExternalGeneratedImage(image=_make_image(), seed=42)])
provider = DummyProvider("openai", configured=True, result=result)
service = ExternalGenerationService({"openai": provider}, logging.getLogger("test"))
response = service.generate(request)
assert response is result
assert provider.last_request == request
def test_generate_resizes_inpaint_result_to_original_init_size() -> None:
model = _build_model(ExternalModelCapabilities(modes=["inpaint"]))
request = _build_request(
model=model,
mode="inpaint",
width=128,
height=128,
init_image=_make_image(),
mask_image=_make_image(),
)
generated_large = Image.new("RGB", (128, 128), color="black")
result = ExternalGenerationResult(images=[ExternalGeneratedImage(image=generated_large, seed=1)])
provider = DummyProvider("openai", configured=True, result=result)
service = ExternalGenerationService({"openai": provider}, logging.getLogger("test"))
response = service.generate(request)
assert request.init_image is not None
assert response.images[0].image.width == request.init_image.width
assert response.images[0].image.height == request.init_image.height
assert response.images[0].seed == 1
def test_qwen_image_edit_max_enforces_three_reference_images() -> None:
from invokeai.backend.model_manager.starter_models import alibabacloud_qwen_image_edit_max
capabilities = alibabacloud_qwen_image_edit_max.capabilities
assert capabilities is not None
assert capabilities.max_reference_images == 3
model = ExternalApiModelConfig(
key="qwen_image_edit_max",
name="Qwen Image Edit Max",
provider_id="alibabacloud",
provider_model_id="qwen-image-edit-max",
capabilities=capabilities,
)
request = _build_request(
model=model,
reference_images=[ExternalReferenceImage(image=_make_image()) for _ in range(4)],
)
provider = DummyProvider("alibabacloud", configured=True, result=ExternalGenerationResult(images=[]))
service = ExternalGenerationService({"alibabacloud": provider}, logging.getLogger("test"))
with pytest.raises(ExternalProviderCapabilityError, match="supports at most 3 reference images"):
service.generate(request)
assert provider.last_request is None
@@ -0,0 +1,393 @@
import io
import logging
import pytest
from PIL import Image
from invokeai.app.services.config.config_default import InvokeAIAppConfig
from invokeai.app.services.external_generation.errors import ExternalProviderRequestError
from invokeai.app.services.external_generation.external_generation_common import (
ExternalGenerationRequest,
ExternalReferenceImage,
)
from invokeai.app.services.external_generation.image_utils import decode_image_base64, encode_image_base64
from invokeai.app.services.external_generation.providers.gemini import GeminiProvider
from invokeai.app.services.external_generation.providers.openai import OpenAIProvider
from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig, ExternalModelCapabilities
class DummyResponse:
def __init__(self, ok: bool, status_code: int = 200, json_data: dict | None = None, text: str = "") -> None:
self.ok = ok
self.status_code = status_code
self._json_data = json_data or {}
self.text = text
self.headers: dict[str, str] = {}
def json(self) -> dict:
return self._json_data
def _make_image(color: str = "black") -> Image.Image:
return Image.new("RGB", (32, 32), color=color)
def _build_model(provider_id: str, provider_model_id: str) -> ExternalApiModelConfig:
return ExternalApiModelConfig(
key=f"{provider_id}_test",
name=f"{provider_id.title()} Test",
provider_id=provider_id,
provider_model_id=provider_model_id,
capabilities=ExternalModelCapabilities(
modes=["txt2img", "img2img", "inpaint"],
supports_reference_images=True,
supports_seed=True,
),
)
def _build_request(
model: ExternalApiModelConfig,
mode: str = "txt2img",
init_image: Image.Image | None = None,
mask_image: Image.Image | None = None,
reference_images: list[ExternalReferenceImage] | None = None,
) -> ExternalGenerationRequest:
return ExternalGenerationRequest(
model=model,
mode=mode, # type: ignore[arg-type]
prompt="A test prompt",
seed=123,
num_images=1,
width=256,
height=256,
image_size=None,
init_image=init_image,
mask_image=mask_image,
reference_images=reference_images or [],
metadata=None,
)
def test_gemini_generate_success(monkeypatch: pytest.MonkeyPatch) -> None:
api_key = "gemini-key"
config = InvokeAIAppConfig(external_gemini_api_key=api_key)
provider = GeminiProvider(config, logging.getLogger("test"))
model = _build_model("gemini", "gemini-2.5-flash-image")
init_image = _make_image("blue")
ref_image = _make_image("red")
request = _build_request(
model,
init_image=init_image,
reference_images=[ExternalReferenceImage(image=ref_image)],
)
encoded = encode_image_base64(_make_image("green"))
captured: dict[str, object] = {}
def fake_post(url: str, params: dict, json: dict, timeout: int) -> DummyResponse:
captured["url"] = url
captured["params"] = params
captured["json"] = json
captured["timeout"] = timeout
return DummyResponse(
ok=True,
json_data={
"candidates": [
{"content": {"parts": [{"inlineData": {"data": encoded}}]}},
]
},
)
monkeypatch.setattr("requests.post", fake_post)
result = provider.generate(request)
assert (
captured["url"]
== "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent"
)
assert captured["params"] == {"key": api_key}
payload = captured["json"]
assert isinstance(payload, dict)
system_instruction = payload.get("systemInstruction")
assert isinstance(system_instruction, dict)
system_parts = system_instruction.get("parts")
assert isinstance(system_parts, list)
system_text = str(system_parts[0]).lower()
assert "image" in system_text
generation_config = payload.get("generationConfig")
assert isinstance(generation_config, dict)
assert generation_config["candidateCount"] == 1
assert generation_config["responseModalities"] == ["IMAGE"]
contents = payload.get("contents")
assert isinstance(contents, list)
first_content = contents[0]
assert isinstance(first_content, dict)
parts = first_content.get("parts")
assert isinstance(parts, list)
assert len(parts) >= 3
part0 = parts[0]
part1 = parts[1]
part2 = parts[2]
assert isinstance(part0, dict)
assert isinstance(part1, dict)
assert isinstance(part2, dict)
inline0 = part0.get("inlineData")
assert isinstance(inline0, dict)
assert part1["text"] == request.prompt
inline1 = part2.get("inlineData")
assert isinstance(inline1, dict)
assert inline0["data"] == encode_image_base64(init_image)
assert inline1["data"] == encode_image_base64(ref_image)
assert result.images[0].seed == request.seed
assert result.provider_metadata == {"model": request.model.provider_model_id}
def test_gemini_generate_error_response(monkeypatch: pytest.MonkeyPatch) -> None:
config = InvokeAIAppConfig(external_gemini_api_key="gemini-key")
provider = GeminiProvider(config, logging.getLogger("test"))
model = _build_model("gemini", "gemini-2.5-flash-image")
request = _build_request(model)
def fake_post(url: str, params: dict, json: dict, timeout: int) -> DummyResponse:
return DummyResponse(ok=False, status_code=400, text="bad request")
monkeypatch.setattr("requests.post", fake_post)
with pytest.raises(ExternalProviderRequestError, match="Gemini request failed"):
provider.generate(request)
def test_gemini_generate_uses_base_url(monkeypatch: pytest.MonkeyPatch) -> None:
config = InvokeAIAppConfig(
external_gemini_api_key="gemini-key",
external_gemini_base_url="https://proxy.gemini",
)
provider = GeminiProvider(config, logging.getLogger("test"))
model = _build_model("gemini", "gemini-2.5-flash-image")
request = _build_request(model)
encoded = encode_image_base64(_make_image("green"))
captured: dict[str, object] = {}
def fake_post(url: str, params: dict, json: dict, timeout: int) -> DummyResponse:
captured["url"] = url
return DummyResponse(
ok=True,
json_data={"candidates": [{"content": {"parts": [{"inlineData": {"data": encoded}}]}}]},
)
monkeypatch.setattr("requests.post", fake_post)
provider.generate(request)
assert captured["url"] == "https://proxy.gemini/v1beta/models/gemini-2.5-flash-image:generateContent"
def test_gemini_generate_keeps_base_url_version(monkeypatch: pytest.MonkeyPatch) -> None:
config = InvokeAIAppConfig(
external_gemini_api_key="gemini-key",
external_gemini_base_url="https://proxy.gemini/v1",
)
provider = GeminiProvider(config, logging.getLogger("test"))
model = _build_model("gemini", "gemini-2.5-flash-image")
request = _build_request(model)
encoded = encode_image_base64(_make_image("green"))
captured: dict[str, object] = {}
def fake_post(url: str, params: dict, json: dict, timeout: int) -> DummyResponse:
captured["url"] = url
return DummyResponse(
ok=True,
json_data={"candidates": [{"content": {"parts": [{"inlineData": {"data": encoded}}]}}]},
)
monkeypatch.setattr("requests.post", fake_post)
provider.generate(request)
assert captured["url"] == "https://proxy.gemini/v1/models/gemini-2.5-flash-image:generateContent"
def test_gemini_generate_strips_models_prefix(monkeypatch: pytest.MonkeyPatch) -> None:
config = InvokeAIAppConfig(external_gemini_api_key="gemini-key")
provider = GeminiProvider(config, logging.getLogger("test"))
model = _build_model("gemini", "models/gemini-2.5-flash-image")
request = _build_request(model)
encoded = encode_image_base64(_make_image("green"))
captured: dict[str, object] = {}
def fake_post(url: str, params: dict, json: dict, timeout: int) -> DummyResponse:
captured["url"] = url
return DummyResponse(
ok=True,
json_data={"candidates": [{"content": {"parts": [{"inlineData": {"data": encoded}}]}}]},
)
monkeypatch.setattr("requests.post", fake_post)
provider.generate(request)
assert (
captured["url"]
== "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent"
)
def test_openai_generate_txt2img_success(monkeypatch: pytest.MonkeyPatch) -> None:
api_key = "openai-key"
config = InvokeAIAppConfig(external_openai_api_key=api_key)
provider = OpenAIProvider(config, logging.getLogger("test"))
model = _build_model("openai", "gpt-image-1")
request = _build_request(model)
encoded = encode_image_base64(_make_image("purple"))
captured: dict[str, object] = {}
def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyResponse:
captured["url"] = url
captured["headers"] = headers
captured["json"] = json
response = DummyResponse(ok=True, json_data={"data": [{"b64_json": encoded}]})
response.headers["x-request-id"] = "req-123"
return response
monkeypatch.setattr("requests.post", fake_post)
result = provider.generate(request)
assert captured["url"] == "https://api.openai.com/v1/images/generations"
headers = captured["headers"]
assert isinstance(headers, dict)
assert headers["Authorization"] == f"Bearer {api_key}"
json_payload = captured["json"]
assert isinstance(json_payload, dict)
assert json_payload["prompt"] == request.prompt
assert result.provider_request_id == "req-123"
assert result.images[0].seed == request.seed
assert decode_image_base64(encoded).size == result.images[0].image.size
def test_openai_generate_uses_base_url(monkeypatch: pytest.MonkeyPatch) -> None:
config = InvokeAIAppConfig(
external_openai_api_key="openai-key",
external_openai_base_url="https://proxy.openai/",
)
provider = OpenAIProvider(config, logging.getLogger("test"))
model = _build_model("openai", "gpt-image-1")
request = _build_request(model)
encoded = encode_image_base64(_make_image("purple"))
captured: dict[str, object] = {}
def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyResponse:
captured["url"] = url
return DummyResponse(ok=True, json_data={"data": [{"b64_json": encoded}]})
monkeypatch.setattr("requests.post", fake_post)
provider.generate(request)
assert captured["url"] == "https://proxy.openai/v1/images/generations"
def test_openai_generate_txt2img_error_response(monkeypatch: pytest.MonkeyPatch) -> None:
config = InvokeAIAppConfig(external_openai_api_key="openai-key")
provider = OpenAIProvider(config, logging.getLogger("test"))
model = _build_model("openai", "gpt-image-1")
request = _build_request(model)
def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyResponse:
return DummyResponse(ok=False, status_code=500, text="server error")
monkeypatch.setattr("requests.post", fake_post)
with pytest.raises(ExternalProviderRequestError, match="OpenAI request failed"):
provider.generate(request)
def test_openai_generate_inpaint_uses_edit_endpoint(monkeypatch: pytest.MonkeyPatch) -> None:
config = InvokeAIAppConfig(external_openai_api_key="openai-key")
provider = OpenAIProvider(config, logging.getLogger("test"))
model = _build_model("openai", "gpt-image-1")
request = _build_request(
model,
mode="inpaint",
init_image=_make_image("white"),
mask_image=_make_image("black"),
)
encoded = encode_image_base64(_make_image("orange"))
captured: dict[str, object] = {}
def fake_post(
url: str,
headers: dict,
data: dict,
files: list[tuple[str, tuple[str, io.BytesIO, str]]],
timeout: int,
) -> DummyResponse:
captured["url"] = url
captured["data"] = data
captured["files"] = files
response = DummyResponse(ok=True, json_data={"data": [{"b64_json": encoded}]})
return response
monkeypatch.setattr("requests.post", fake_post)
result = provider.generate(request)
assert captured["url"] == "https://api.openai.com/v1/images/edits"
data_payload = captured["data"]
assert isinstance(data_payload, dict)
assert data_payload["prompt"] == request.prompt
files = captured["files"]
assert isinstance(files, list)
image_file = next((file for file in files if file[0] == "image"), None)
mask_file = next((file for file in files if file[0] == "mask"), None)
assert image_file is not None
assert mask_file is not None
image_tuple = image_file[1]
assert isinstance(image_tuple, tuple)
assert image_tuple[0] == "image_0.png"
assert isinstance(image_tuple[1], io.BytesIO)
assert result.images
def test_openai_generate_txt2img_with_references_uses_edit_endpoint(monkeypatch: pytest.MonkeyPatch) -> None:
config = InvokeAIAppConfig(external_openai_api_key="openai-key")
provider = OpenAIProvider(config, logging.getLogger("test"))
model = _build_model("openai", "gpt-image-1")
request = _build_request(
model,
reference_images=[
ExternalReferenceImage(image=_make_image("red")),
ExternalReferenceImage(image=_make_image("blue")),
],
)
encoded = encode_image_base64(_make_image("orange"))
captured: dict[str, object] = {}
def fake_post(
url: str,
headers: dict,
data: dict,
files: list[tuple[str, tuple[str, io.BytesIO, str]]],
timeout: int,
) -> DummyResponse:
captured["url"] = url
captured["data"] = data
captured["files"] = files
return DummyResponse(ok=True, json_data={"data": [{"b64_json": encoded}]})
monkeypatch.setattr("requests.post", fake_post)
result = provider.generate(request)
assert captured["url"] == "https://api.openai.com/v1/images/edits"
data_payload = captured["data"]
assert isinstance(data_payload, dict)
assert data_payload["prompt"] == request.prompt
files = captured["files"]
assert isinstance(files, list)
image_files = [file for file in files if file[0] == "image[]"]
assert len(image_files) == 2
assert image_files[0][1][0] == "image_0.png"
assert image_files[1][1][0] == "image_1.png"
assert result.images
@@ -0,0 +1,354 @@
import logging
import pytest
from PIL import Image
from invokeai.app.services.config.config_default import InvokeAIAppConfig
from invokeai.app.services.external_generation.errors import (
ExternalProviderCapabilityError,
ExternalProviderRequestError,
)
from invokeai.app.services.external_generation.external_generation_common import (
ExternalGenerationRequest,
ExternalReferenceImage,
)
from invokeai.app.services.external_generation.image_utils import encode_image_base64
from invokeai.app.services.external_generation.providers.seedream import SeedreamProvider
from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig, ExternalModelCapabilities
class DummyResponse:
def __init__(self, ok: bool, status_code: int = 200, json_data: dict | None = None, text: str = "") -> None:
self.ok = ok
self.status_code = status_code
self._json_data = json_data or {}
self.text = text
self.headers: dict[str, str] = {}
def json(self) -> dict:
return self._json_data
def _make_image(color: str = "black") -> Image.Image:
return Image.new("RGB", (32, 32), color=color)
def _build_model(provider_model_id: str, modes: list[str] | None = None) -> ExternalApiModelConfig:
return ExternalApiModelConfig(
key="seedream_test",
name="Seedream Test",
provider_id="seedream",
provider_model_id=provider_model_id,
capabilities=ExternalModelCapabilities(
modes=modes or ["txt2img", "img2img"],
supports_reference_images=True,
supports_seed=True,
),
)
def _build_request(
model: ExternalApiModelConfig,
mode: str = "txt2img",
init_image: Image.Image | None = None,
reference_images: list[ExternalReferenceImage] | None = None,
num_images: int = 1,
seed: int | None = 123,
provider_options: dict | None = None,
) -> ExternalGenerationRequest:
return ExternalGenerationRequest(
model=model,
mode=mode, # type: ignore[arg-type]
prompt="A test prompt",
seed=seed,
num_images=num_images,
width=2048,
height=2048,
image_size=None,
init_image=init_image,
mask_image=None,
reference_images=reference_images or [],
metadata=None,
provider_options=provider_options,
)
def test_seedream_is_configured() -> None:
config = InvokeAIAppConfig(external_seedream_api_key="test-key")
provider = SeedreamProvider(config, logging.getLogger("test"))
assert provider.is_configured() is True
def test_seedream_not_configured() -> None:
config = InvokeAIAppConfig()
provider = SeedreamProvider(config, logging.getLogger("test"))
assert provider.is_configured() is False
def test_seedream_txt2img_success(monkeypatch: pytest.MonkeyPatch) -> None:
api_key = "seedream-key"
config = InvokeAIAppConfig(external_seedream_api_key=api_key)
provider = SeedreamProvider(config, logging.getLogger("test"))
model = _build_model("seedream-4-5-251128")
request = _build_request(model)
encoded = encode_image_base64(_make_image("green"))
captured: dict[str, object] = {}
def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyResponse:
captured["url"] = url
captured["headers"] = headers
captured["json"] = json
return DummyResponse(ok=True, json_data={"data": [{"b64_json": encoded}]})
monkeypatch.setattr("requests.post", fake_post)
result = provider.generate(request)
assert captured["url"] == "https://ark.ap-southeast.bytepluses.com/api/v3/images/generations"
headers = captured["headers"]
assert isinstance(headers, dict)
assert headers["Authorization"] == f"Bearer {api_key}"
json_payload = captured["json"]
assert isinstance(json_payload, dict)
assert json_payload["model"] == "seedream-4-5-251128"
assert json_payload["prompt"] == "A test prompt"
assert json_payload["size"] == "2048x2048"
assert json_payload["response_format"] == "b64_json"
assert json_payload["watermark"] is False
assert json_payload["sequential_image_generation"] == "disabled"
# Seed should not be sent for 4.x models
assert "seed" not in json_payload
# Guidance should not be sent for 4.x models
assert "guidance_scale" not in json_payload
assert len(result.images) == 1
assert result.images[0].seed == 123
def test_seedream_3_0_t2i_sends_seed_and_guidance(monkeypatch: pytest.MonkeyPatch) -> None:
config = InvokeAIAppConfig(external_seedream_api_key="seedream-key")
provider = SeedreamProvider(config, logging.getLogger("test"))
model = _build_model("seedream-3-0-t2i-250415", modes=["txt2img"])
request = _build_request(model, seed=42, provider_options={"guidance_scale": 2.5})
encoded = encode_image_base64(_make_image("green"))
captured: dict[str, object] = {}
def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyResponse:
captured["json"] = json
return DummyResponse(ok=True, json_data={"data": [{"b64_json": encoded}]})
monkeypatch.setattr("requests.post", fake_post)
provider.generate(request)
json_payload = captured["json"]
assert isinstance(json_payload, dict)
assert json_payload["seed"] == 42
assert json_payload["guidance_scale"] == 2.5
# 3.0 models should not have sequential_image_generation
assert "sequential_image_generation" not in json_payload
def test_seedream_batch_generation(monkeypatch: pytest.MonkeyPatch) -> None:
config = InvokeAIAppConfig(external_seedream_api_key="seedream-key")
provider = SeedreamProvider(config, logging.getLogger("test"))
model = _build_model("seedream-4-5-251128")
request = _build_request(model, num_images=3)
encoded = encode_image_base64(_make_image("green"))
captured: dict[str, object] = {}
def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyResponse:
captured["json"] = json
return DummyResponse(
ok=True,
json_data={"data": [{"b64_json": encoded}, {"b64_json": encoded}, {"b64_json": encoded}]},
)
monkeypatch.setattr("requests.post", fake_post)
result = provider.generate(request)
json_payload = captured["json"]
assert isinstance(json_payload, dict)
assert json_payload["sequential_image_generation"] == "auto"
assert json_payload["sequential_image_generation_options"] == {"max_images": 3}
assert len(result.images) == 3
def test_seedream_img2img_with_reference_images(monkeypatch: pytest.MonkeyPatch) -> None:
config = InvokeAIAppConfig(external_seedream_api_key="seedream-key")
provider = SeedreamProvider(config, logging.getLogger("test"))
model = _build_model("seedream-4-5-251128")
init_image = _make_image("blue")
ref_image = _make_image("red")
request = _build_request(
model,
mode="img2img",
init_image=init_image,
reference_images=[ExternalReferenceImage(image=ref_image)],
)
encoded = encode_image_base64(_make_image("green"))
captured: dict[str, object] = {}
def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyResponse:
captured["json"] = json
return DummyResponse(ok=True, json_data={"data": [{"b64_json": encoded}]})
monkeypatch.setattr("requests.post", fake_post)
result = provider.generate(request)
json_payload = captured["json"]
assert isinstance(json_payload, dict)
images = json_payload["image"]
assert isinstance(images, list)
assert len(images) == 2 # init_image + reference
assert images[0].startswith("data:image/png;base64,")
assert images[1].startswith("data:image/png;base64,")
assert len(result.images) == 1
def test_seedream_single_image_not_array(monkeypatch: pytest.MonkeyPatch) -> None:
config = InvokeAIAppConfig(external_seedream_api_key="seedream-key")
provider = SeedreamProvider(config, logging.getLogger("test"))
model = _build_model("seedream-3-0-t2i-250415", modes=["txt2img"])
init_image = _make_image("blue")
request = _build_request(model, mode="txt2img", init_image=init_image, provider_options={"guidance_scale": 5.5})
encoded = encode_image_base64(_make_image("green"))
captured: dict[str, object] = {}
def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyResponse:
captured["json"] = json
return DummyResponse(ok=True, json_data={"data": [{"b64_json": encoded}]})
monkeypatch.setattr("requests.post", fake_post)
provider.generate(request)
json_payload = captured["json"]
assert isinstance(json_payload, dict)
# Single image should be a string, not an array
image = json_payload["image"]
assert isinstance(image, str)
assert image.startswith("data:image/png;base64,")
def test_seedream_error_response(monkeypatch: pytest.MonkeyPatch) -> None:
config = InvokeAIAppConfig(external_seedream_api_key="seedream-key")
provider = SeedreamProvider(config, logging.getLogger("test"))
model = _build_model("seedream-4-5-251128")
request = _build_request(model)
def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyResponse:
return DummyResponse(ok=False, status_code=400, text="bad request")
monkeypatch.setattr("requests.post", fake_post)
with pytest.raises(ExternalProviderRequestError, match="Seedream request failed"):
provider.generate(request)
def test_seedream_no_api_key_raises(monkeypatch: pytest.MonkeyPatch) -> None:
config = InvokeAIAppConfig()
provider = SeedreamProvider(config, logging.getLogger("test"))
model = _build_model("seedream-4-5-251128")
request = _build_request(model)
with pytest.raises(ExternalProviderRequestError, match="API key is not configured"):
provider.generate(request)
def test_seedream_uses_base_url(monkeypatch: pytest.MonkeyPatch) -> None:
config = InvokeAIAppConfig(
external_seedream_api_key="seedream-key",
external_seedream_base_url="https://proxy.seedream/",
)
provider = SeedreamProvider(config, logging.getLogger("test"))
model = _build_model("seedream-4-5-251128")
request = _build_request(model)
encoded = encode_image_base64(_make_image("green"))
captured: dict[str, object] = {}
def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyResponse:
captured["url"] = url
return DummyResponse(ok=True, json_data={"data": [{"b64_json": encoded}]})
monkeypatch.setattr("requests.post", fake_post)
provider.generate(request)
assert captured["url"] == "https://proxy.seedream/api/v3/images/generations"
def test_seedream_batch_surfaces_partial_failures(monkeypatch: pytest.MonkeyPatch) -> None:
config = InvokeAIAppConfig(external_seedream_api_key="seedream-key")
provider = SeedreamProvider(config, logging.getLogger("test"))
model = _build_model("seedream-4-5-251128")
request = _build_request(model, num_images=3)
encoded = encode_image_base64(_make_image("green"))
def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyResponse:
return DummyResponse(
ok=True,
json_data={
"data": [
{"b64_json": encoded},
{"error": {"code": "content_filter", "message": "filtered"}},
{"b64_json": encoded},
]
},
)
monkeypatch.setattr("requests.post", fake_post)
result = provider.generate(request)
assert len(result.images) == 2
assert result.provider_metadata is not None
partial_failures = result.provider_metadata.get("partial_failures")
assert isinstance(partial_failures, list) and len(partial_failures) == 1
assert partial_failures[0] == {"code": "content_filter", "message": "filtered"}
def test_seedream_batch_all_items_failed_raises(monkeypatch: pytest.MonkeyPatch) -> None:
config = InvokeAIAppConfig(external_seedream_api_key="seedream-key")
provider = SeedreamProvider(config, logging.getLogger("test"))
model = _build_model("seedream-4-5-251128")
request = _build_request(model, num_images=2)
def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyResponse:
return DummyResponse(
ok=True,
json_data={
"data": [
{"error": {"code": "content_filter", "message": "filtered"}},
{"error": {"code": "content_filter", "message": "filtered"}},
]
},
)
monkeypatch.setattr("requests.post", fake_post)
with pytest.raises(ExternalProviderRequestError, match="filtered"):
provider.generate(request)
def test_seedream_rejects_combined_reference_and_output_count(monkeypatch: pytest.MonkeyPatch) -> None:
config = InvokeAIAppConfig(external_seedream_api_key="seedream-key")
provider = SeedreamProvider(config, logging.getLogger("test"))
model = _build_model("seedream-4-5-251128")
references = [ExternalReferenceImage(image=_make_image("red")) for _ in range(14)]
request = _build_request(model, num_images=15, reference_images=references)
posted = False
def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyResponse:
nonlocal posted
posted = True
return DummyResponse(ok=True, json_data={"data": []})
monkeypatch.setattr("requests.post", fake_post)
with pytest.raises(ExternalProviderCapabilityError, match="15 images total"):
provider.generate(request)
assert posted is False
@@ -0,0 +1,56 @@
from unittest.mock import MagicMock
from invokeai.app.services.external_generation.startup import sync_configured_external_starter_models
from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig, ExternalModelCapabilities
def _build_installed_model(source: str) -> ExternalApiModelConfig:
provider_id, provider_model_id = source.removeprefix("external://").split("/", 1)
return ExternalApiModelConfig(
key=f"{provider_id}-{provider_model_id}",
name=provider_model_id,
source=source,
provider_id=provider_id,
provider_model_id=provider_model_id,
capabilities=ExternalModelCapabilities(modes=["txt2img"]),
)
def test_sync_configured_external_starter_models_queues_missing_models_for_configured_providers() -> None:
model_manager = MagicMock()
model_manager.store.search_by_attr.return_value = [
_build_installed_model("external://openai/gpt-image-1"),
]
logger = MagicMock()
queued_sources = sync_configured_external_starter_models(
configured_provider_ids={"gemini", "openai"},
model_manager=model_manager,
logger=logger,
)
assert "external://openai/gpt-image-1" not in queued_sources
assert "external://gemini/gemini-2.5-flash-image" in queued_sources
assert "external://gemini/gemini-3.1-flash-image-preview" in queued_sources
assert "external://gemini/gemini-3-pro-image-preview" in queued_sources
install_calls = [call.args[0] for call in model_manager.install.heuristic_import.call_args_list]
assert "external://openai/gpt-image-1" not in install_calls
assert "external://gemini/gemini-2.5-flash-image" in install_calls
assert "external://gemini/gemini-3.1-flash-image-preview" in install_calls
assert "external://gemini/gemini-3-pro-image-preview" in install_calls
def test_sync_configured_external_starter_models_skips_when_no_provider_is_configured() -> None:
model_manager = MagicMock()
logger = MagicMock()
queued_sources = sync_configured_external_starter_models(
configured_provider_ids=set(),
model_manager=model_manager,
logger=logger,
)
assert queued_sources == []
model_manager.store.search_by_attr.assert_not_called()
model_manager.install.heuristic_import.assert_not_called()
@@ -0,0 +1,254 @@
import hashlib
import platform
import zlib
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from PIL import Image
from invokeai.app.services.image_files.image_files_disk import DiskImageFileStorage, _should_use_png_rle
from invokeai.app.util.thumbnails import get_thumbnail_name
@pytest.fixture
def image_names() -> list[str]:
# Determine the platform and return a path that matches its format
if platform.system() == "Windows":
return [
# Relative paths
"folder\\evil.txt",
"folder\\..\\evil.txt",
# Absolute paths
"\\folder\\evil.txt",
"C:\\folder\\..\\evil.txt",
]
else:
return [
# Relative paths
"folder/evil.txt",
"folder/../evil.txt",
# Absolute paths
"/folder/evil.txt",
"/folder/../evil.txt",
]
@pytest.fixture
def disk_storage(tmp_path: Path) -> DiskImageFileStorage:
storage = DiskImageFileStorage(tmp_path)
# Mock the invoker for save() which needs compress_level
mock_invoker = MagicMock()
mock_invoker.services.configuration.pil_compress_level = 6
storage._DiskImageFileStorage__invoker = mock_invoker # type: ignore
return storage
def test_directory_traversal_protection(tmp_path: Path, image_names: list[str]):
"""Test that the image file storage prevents directory traversal attacks.
There are two safeguards in the `DiskImageFileStorage.get_path` method:
1. Check if the image name contains any directory traversal characters
2. Check if the resulting path is relative to the base folder
This test checks the first safeguard. I'd like to check the second but I cannot figure out a test case that would
pass the first check but fail the second check.
"""
image_files_disk = DiskImageFileStorage(tmp_path)
for name in image_names:
with pytest.raises(ValueError, match="Invalid image name, potential directory traversal detected"):
image_files_disk.get_path(name)
def test_image_paths_relative_to_storage_dir(tmp_path: Path):
image_files_disk = DiskImageFileStorage(tmp_path)
path = image_files_disk.get_path("foo.png")
assert path.is_relative_to(tmp_path)
@pytest.mark.parametrize(
("compress_level", "expected_compress_type"),
[(0, None), (1, zlib.Z_RLE), (7, None)],
)
def test_save_uses_rle_only_for_compression_level_one(
tmp_path: Path, compress_level: int, expected_compress_type: int | None
):
storage = DiskImageFileStorage(tmp_path)
mock_invoker = MagicMock()
mock_invoker.services.configuration.pil_compress_level = compress_level
storage._DiskImageFileStorage__invoker = mock_invoker # type: ignore
with (
patch("invokeai.app.services.image_files.image_files_disk._should_use_png_rle", return_value=True),
patch.object(Image.Image, "save", autospec=True) as save_mock,
):
storage.save(image=Image.new("RGBA", (32, 32)), image_name="test.png")
png_calls = [call for call in save_mock.call_args_list if len(call.args) > 2 and call.args[2] == "PNG"]
assert len(png_calls) == 1
assert png_calls[0].kwargs["compress_level"] == compress_level
if expected_compress_type is None:
assert "compress_type" not in png_calls[0].kwargs
else:
assert png_calls[0].kwargs["compress_type"] == expected_compress_type
def test_png_rle_probe_rejects_structured_images():
entropy = Image.frombytes("RGB", (512, 512), hashlib.shake_256(b"png-rle-test").digest(512 * 512 * 3))
gradient = Image.linear_gradient("L").resize((512, 512)).convert("RGB")
assert _should_use_png_rle(entropy)
assert not _should_use_png_rle(gradient)
entropy.close()
gradient.close()
def _make_round_trip_image(mode: str) -> Image.Image:
image = Image.new(mode, (4, 4))
if mode == "P":
palette = [component for index in range(256) for component in (index, 255 - index, index // 2, index)]
image.putpalette(palette, rawmode="RGBA")
image.putdata(range(16))
else:
values = {
"1": [0, 1],
"L": [0, 255],
"LA": [(17, 0), (201, 255)],
"RGB": [(1, 2, 3), (251, 252, 253)],
"RGBA": [(1, 2, 3, 0), (251, 252, 253, 255)],
"I;16": [0, 65535],
}
image.putdata(values[mode] * 8)
return image
@pytest.mark.parametrize("mode", ["1", "L", "LA", "P", "RGB", "RGBA", "I;16"])
def test_level_one_png_round_trip_from_disk(tmp_path: Path, mode: str):
storage = DiskImageFileStorage(tmp_path)
mock_invoker = MagicMock()
mock_invoker.services.configuration.pil_compress_level = 1
storage._DiskImageFileStorage__invoker = mock_invoker # type: ignore
image = _make_round_trip_image(mode)
expected_bytes = image.tobytes()
expected_rgba = image.convert("RGBA").tobytes() if mode == "P" else None
metadata = f'{{"mode":"{mode}"}}'
image_name = f"round-trip-{mode.replace(';', '-')}.png"
with patch("invokeai.app.services.image_files.image_files_disk._should_use_png_rle", return_value=True):
storage.save(image=image, image_name=image_name, metadata=metadata)
image_path = storage.get_path(image_name)
storage.evict_cache_paths([image_path])
with Image.open(image_path) as loaded:
loaded.load()
assert loaded.format == "PNG"
assert loaded.mode == mode
assert loaded.tobytes() == expected_bytes
assert loaded.info["invokeai_metadata"] == metadata
if mode in {"LA", "RGBA"}:
assert loaded.getchannel("A").tobytes() == image.getchannel("A").tobytes()
if mode == "P":
assert loaded.info["transparency"] == bytes(range(256))
assert loaded.convert("RGBA").tobytes() == expected_rgba
image.close()
# ── Subfolder validation tests (Point 1) ──
class TestValidateSubfolder:
"""Tests for _validate_subfolder() and get_path() with image_subfolder."""
def test_valid_single_segment(self, tmp_path: Path):
storage = DiskImageFileStorage(tmp_path)
path = storage.get_path("img.png", image_subfolder="general")
assert path.is_relative_to(tmp_path)
assert "general" in path.parts
def test_valid_nested_subfolder(self, tmp_path: Path):
storage = DiskImageFileStorage(tmp_path)
path = storage.get_path("img.png", image_subfolder="2026/03/17")
assert path.is_relative_to(tmp_path)
assert path.name == "img.png"
@pytest.mark.parametrize(
"subfolder,error_match",
[
("../x", "Parent directory references not allowed"),
("x/../y", "Parent directory references not allowed"),
("/abs", "Absolute paths not allowed"),
("a//b", "Empty path segments not allowed"),
("a\\b", "Backslashes not allowed"),
],
ids=["parent_traversal", "mid_traversal", "absolute", "double_slash", "backslash"],
)
def test_invalid_subfolders(self, tmp_path: Path, subfolder: str, error_match: str):
storage = DiskImageFileStorage(tmp_path)
with pytest.raises(ValueError, match=error_match):
storage.get_path("img.png", image_subfolder=subfolder)
def test_empty_subfolder_gives_root(self, tmp_path: Path):
storage = DiskImageFileStorage(tmp_path)
path = storage.get_path("img.png", image_subfolder="")
assert path == (tmp_path / "img.png").resolve()
def test_thumbnail_mirrors_subfolder(self, tmp_path: Path):
storage = DiskImageFileStorage(tmp_path)
subfolder = "2026/03/17"
img_path = storage.get_path("img.png", thumbnail=False, image_subfolder=subfolder)
thumb_path = storage.get_path("img.png", thumbnail=True, image_subfolder=subfolder)
# Both should contain the subfolder segments
assert subfolder.replace("/", "\\") in str(img_path) or subfolder in str(img_path)
assert subfolder.replace("/", "\\") in str(thumb_path) or subfolder in str(thumb_path)
# Thumbnail should be under thumbnails folder
thumbnails_folder = (tmp_path / "thumbnails").resolve()
assert thumb_path.is_relative_to(thumbnails_folder)
class TestSaveDeleteRoundTrip:
"""Save/delete round-trip with subfolders, including thumbnail mirroring."""
def test_save_and_delete_with_subfolder(self, disk_storage: DiskImageFileStorage, tmp_path: Path):
subfolder = "2026/04/05"
image_name = "test_image.png"
image = Image.new("RGB", (64, 64), color="red")
disk_storage.save(image=image, image_name=image_name, image_subfolder=subfolder)
# Image file exists
image_path = disk_storage.get_path(image_name, image_subfolder=subfolder)
assert image_path.exists()
# Thumbnail file exists in mirrored subfolder
thumbnail_name = get_thumbnail_name(image_name)
thumb_path = disk_storage.get_path(image_name, thumbnail=True, image_subfolder=subfolder)
assert thumb_path.name == thumbnail_name
assert not thumb_path.name.startswith("thumbnail_thumbnail_")
assert thumb_path.exists()
# Round-trip read
loaded = disk_storage.get(image_name, image_subfolder=subfolder)
assert loaded.size == (64, 64)
# Delete removes both
disk_storage.delete(image_name, image_subfolder=subfolder)
assert not image_path.exists()
assert not thumb_path.exists()
def test_save_flat_and_subfolder_coexist(self, disk_storage: DiskImageFileStorage, tmp_path: Path):
image = Image.new("RGB", (32, 32), color="blue")
disk_storage.save(image=image, image_name="flat.png", image_subfolder="")
disk_storage.save(image=image, image_name="nested.png", image_subfolder="general")
flat_path = disk_storage.get_path("flat.png", image_subfolder="")
nested_path = disk_storage.get_path("nested.png", image_subfolder="general")
assert flat_path.exists()
assert nested_path.exists()
assert flat_path.parent != nested_path.parent
@@ -0,0 +1,727 @@
import os
import threading
from pathlib import Path
from shutil import copy2
from unittest.mock import MagicMock, patch
import pytest
from PIL import Image
from invokeai.app.services.config.config_default import InvokeAIAppConfig
from invokeai.app.services.image_files.image_files_disk import DiskImageFileStorage
from invokeai.app.services.image_moves.image_moves_default import (
ImageMoveQueueActive,
ImageMoveService,
)
from invokeai.app.services.image_records.image_records_common import ImageCategory, ResourceOrigin
from invokeai.app.services.image_records.image_records_sqlite import SqliteImageRecordStorage
from invokeai.app.services.session_queue.session_queue_common import DEFAULT_QUEUE_ID, SessionQueueStatus
from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
from invokeai.app.services.shared.sqlite.sqlite_util import init_db
from invokeai.backend.util.logging import InvokeAILogger
def _build_db(tmp_path: Path) -> SqliteDatabase:
logger = InvokeAILogger.get_logger()
config = InvokeAIAppConfig(use_memory_db=False)
config._root = tmp_path
image_files = DiskImageFileStorage(tmp_path / "images")
return init_db(config=config, logger=logger, image_files=image_files)
def _save_record(
records: SqliteImageRecordStorage,
image_name: str,
subfolder: str,
created_at: str,
is_intermediate: bool = False,
) -> None:
records.save(
image_name=image_name,
image_origin=ResourceOrigin.INTERNAL,
image_category=ImageCategory.GENERAL,
width=16,
height=16,
has_workflow=False,
is_intermediate=is_intermediate,
image_subfolder=subfolder,
)
with records._db.transaction() as cursor:
cursor.execute("UPDATE images SET created_at = ? WHERE image_name = ?;", (created_at, image_name))
def _save_image(
service: ImageMoveService,
records: SqliteImageRecordStorage,
image_name: str,
subfolder: str,
created_at: str,
color: str,
is_intermediate: bool = False,
) -> None:
_save_record(
records,
image_name=image_name,
subfolder=subfolder,
created_at=created_at,
is_intermediate=is_intermediate,
)
service.image_files.save(Image.new("RGB", (16, 16), color), image_name=image_name, image_subfolder=subfolder)
def _service(tmp_path: Path, strategy: str = "date") -> tuple[ImageMoveService, SqliteImageRecordStorage]:
db = _build_db(tmp_path)
records = SqliteImageRecordStorage(db=db)
storage = DiskImageFileStorage(tmp_path / "images")
invoker = MagicMock()
invoker.services.configuration.pil_compress_level = 6
storage.start(invoker)
config = InvokeAIAppConfig(use_memory_db=True, image_subfolder_strategy=strategy)
config._root = tmp_path
service = ImageMoveService(db=db, image_files=storage, config=config, logger=InvokeAILogger.get_logger())
return service, records
def _job_item_states(service: ImageMoveService, job_id: int) -> dict[str, str]:
with service._db.transaction() as cursor:
cursor.execute(
"SELECT image_name, state FROM image_subfolder_move_items WHERE job_id = ? ORDER BY image_name;",
(job_id,),
)
return {row["image_name"]: row["state"] for row in cursor.fetchall()}
def _job_states(service: ImageMoveService) -> dict[int, str]:
with service._db.transaction() as cursor:
cursor.execute("SELECT id, state FROM image_subfolder_move_jobs ORDER BY id;")
return {row["id"]: row["state"] for row in cursor.fetchall()}
def test_move_all_images_uses_created_at_for_date_strategy(tmp_path: Path) -> None:
service, records = _service(tmp_path, strategy="date")
image_name = "image-a.png"
_save_record(records, image_name=image_name, subfolder="", created_at="2024-02-03 04:05:06.000")
service.image_files.save(Image.new("RGB", (16, 16), "red"), image_name=image_name)
result = service.move_all_images()
assert result.planned == 1
assert result.committed == 1
record = records.get(image_name)
assert record.image_subfolder == "2024/02/03"
assert service.image_files.get_path(image_name, image_subfolder="2024/02/03").exists()
assert not service.image_files.get_path(image_name, image_subfolder="").exists()
def test_missing_intermediate_source_file_is_treated_as_success(tmp_path: Path) -> None:
service, records = _service(tmp_path, strategy="date")
image_name = "missing-intermediate.png"
_save_record(
records,
image_name=image_name,
subfolder="",
created_at="2024-02-04 04:05:06.000",
is_intermediate=True,
)
result = service.move_all_images()
assert result.planned == 1
assert result.committed == 1
assert result.errors == 0
record = records.get(image_name)
assert record.image_subfolder == "2024/02/04"
assert service.get_latest_job().state == "committed"
def test_missing_intermediate_source_file_removes_orphaned_thumbnail(tmp_path: Path) -> None:
service, records = _service(tmp_path, strategy="date")
image_name = "missing-intermediate-with-thumbnail.png"
old_subfolder = "old/intermediate"
_save_image(
service,
records,
image_name=image_name,
subfolder=old_subfolder,
created_at="2024-02-04 04:05:06.000",
color="red",
is_intermediate=True,
)
old_path = service.image_files.get_path(image_name, image_subfolder=old_subfolder)
old_thumbnail_path = service.image_files.get_path(image_name, thumbnail=True, image_subfolder=old_subfolder)
assert old_thumbnail_path.exists()
old_path.unlink()
result = service.move_all_images()
assert result.committed == 1
assert not old_thumbnail_path.exists()
assert records.get(image_name).image_subfolder == "2024/02/04"
def test_missing_non_intermediate_source_file_still_fails(tmp_path: Path) -> None:
service, records = _service(tmp_path, strategy="date")
image_name = "missing-general.png"
_save_record(
records,
image_name=image_name,
subfolder="",
created_at="2024-02-04 04:05:06.000",
is_intermediate=False,
)
with pytest.raises(FileNotFoundError, match="Source image does not exist"):
service.plan_batch(last_image_name="", limit=100)
def test_move_all_images_continues_after_missing_non_intermediate_source_file(tmp_path: Path) -> None:
service, records = _service(tmp_path, strategy="date")
missing_image_name = "missing-general.png"
valid_image_name = "valid-general.png"
_save_record(
records,
image_name=missing_image_name,
subfolder="",
created_at="2024-02-04 04:05:06.000",
is_intermediate=False,
)
_save_image(service, records, valid_image_name, "", "2024-02-05 04:05:06.000", "blue")
result = service.move_all_images()
assert result.errors == 1
assert result.committed == 1
assert records.get(missing_image_name).image_subfolder == ""
assert records.get(valid_image_name).image_subfolder == "2024/02/05"
assert "error" in _job_states(service).values()
assert "committed" in _job_states(service).values()
def test_recovery_treats_missing_intermediate_source_file_as_success(tmp_path: Path) -> None:
service, records = _service(tmp_path, strategy="date")
image_name = "missing-intermediate-recovery.png"
_save_record(
records,
image_name=image_name,
subfolder="",
created_at="2024-02-05 04:05:06.000",
is_intermediate=True,
)
moves = service.plan_batch(last_image_name="", limit=100)
job_id = service.create_move_job(moves)
recovered = service.startup_recovery()
assert recovered.committed == 1
assert recovered.errors == 0
assert records.get(image_name).image_subfolder == "2024/02/05"
assert service.get_job(job_id).state == "committed"
assert _job_item_states(service, job_id) == {image_name: "committed"}
def test_startup_recovery_commits_after_files_moved_but_db_not_updated(tmp_path: Path) -> None:
service, records = _service(tmp_path, strategy="date")
image_name = "image-b.png"
_save_record(records, image_name=image_name, subfolder="", created_at="2025-06-07 08:09:10.000")
service.image_files.save(Image.new("RGB", (16, 16), "blue"), image_name=image_name)
moves = service.plan_batch(last_image_name="", limit=100)
job_id = service.create_move_job(moves)
service.perform_filesystem_moves(job_id)
assert records.get(image_name).image_subfolder == ""
recovered = service.startup_recovery()
assert recovered.committed == 1
assert records.get(image_name).image_subfolder == "2025/06/07"
assert service.get_job(job_id).state == "committed"
def test_status_reports_unplanned_images_after_recovery(tmp_path: Path) -> None:
service, records = _service(tmp_path, strategy="date")
_save_image(service, records, "image-recovered.png", "", "2024-02-03 04:05:06.000", "red")
_save_image(service, records, "image-unplanned.png", "", "2024-02-04 04:05:06.000", "blue")
moves = service.plan_batch(last_image_name="", limit=1)
job_id = service.create_move_job(moves)
service.perform_filesystem_moves(job_id)
recovered = service.startup_recovery()
status = service.get_background_status()
assert recovered.committed == 1
assert records.get("image-recovered.png").image_subfolder == "2024/02/03"
assert records.get("image-unplanned.png").image_subfolder == ""
assert status.active_job_id is None
assert status.latest_job is not None
assert status.latest_job.state == "committed"
assert status.needs_move_count == 1
def test_cleanup_empty_source_directories_after_move(tmp_path: Path) -> None:
service, records = _service(tmp_path, strategy="date")
image_name = "image-c.png"
old_subfolder = "old/nested"
_save_record(records, image_name=image_name, subfolder=old_subfolder, created_at="2024-11-12 01:02:03.000")
service.image_files.save(Image.new("RGB", (16, 16), "green"), image_name=image_name, image_subfolder=old_subfolder)
old_parent = service.image_files.get_path(image_name, image_subfolder=old_subfolder).parent
old_thumb_parent = service.image_files.get_path(image_name, thumbnail=True, image_subfolder=old_subfolder).parent
service.move_all_images()
assert not old_parent.exists()
assert not old_thumb_parent.exists()
assert service.image_files.image_root.exists()
assert service.image_files.thumbnail_root.exists()
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are not supported on this platform")
def test_cleanup_empty_source_directories_stays_within_symlinked_root(tmp_path: Path) -> None:
service, _records = _service(tmp_path, strategy="date")
real_root = tmp_path / "real-root"
linked_root = tmp_path / "linked-root"
sibling = tmp_path / "sibling"
real_root.mkdir()
sibling.mkdir()
try:
linked_root.symlink_to(real_root, target_is_directory=True)
except OSError as e:
pytest.skip(f"symlink creation is not available: {e}")
nested = linked_root / "old" / "nested"
nested.mkdir(parents=True)
service._remove_empty_parents(nested, linked_root)
assert real_root.exists()
assert linked_root.exists()
assert sibling.exists()
assert not (real_root / "old").exists()
def test_startup_recovery_cleans_empty_source_directories(tmp_path: Path) -> None:
service, records = _service(tmp_path, strategy="date")
image_name = "image-recovery-cleanup.png"
old_subfolder = "old/recovery"
_save_image(service, records, image_name, old_subfolder, "2024-11-13 01:02:03.000", "green")
moves = service.plan_batch(last_image_name="", limit=100)
job_id = service.create_move_job(moves)
move = moves[0]
old_parent = service.image_files.get_path(image_name, image_subfolder=old_subfolder).parent
old_thumb_parent = service.image_files.get_path(image_name, thumbnail=True, image_subfolder=old_subfolder).parent
move.new_path.parent.mkdir(parents=True, exist_ok=True)
move.new_thumbnail_path.parent.mkdir(parents=True, exist_ok=True)
move.old_path.replace(move.new_path)
move.old_thumbnail_path.replace(move.new_thumbnail_path)
recovered = service.startup_recovery()
assert recovered.committed == 1
assert recovered.errors == 0
assert not old_parent.exists()
assert not old_thumb_parent.exists()
assert service.get_job(job_id).state == "committed"
def test_preflight_rejects_active_uncommitted_job_for_same_image(tmp_path: Path) -> None:
service, records = _service(tmp_path, strategy="date")
image_name = "image-d.png"
_save_record(records, image_name=image_name, subfolder="", created_at="2024-01-02 03:04:05.000")
service.image_files.save(Image.new("RGB", (16, 16), "yellow"), image_name=image_name)
moves = service.plan_batch(last_image_name="", limit=100)
service.create_move_job(moves)
with pytest.raises(ValueError, match="active image move job"):
service.plan_batch(last_image_name="", limit=100)
def test_create_move_job_rejects_second_active_job_from_stale_plan(tmp_path: Path) -> None:
service, records = _service(tmp_path, strategy="date")
image_name = "image-active-race.png"
_save_image(service, records, image_name, "", "2024-01-03 03:04:05.000", "yellow")
stale_plan_a = service.plan_batch(last_image_name="", limit=100)
stale_plan_b = service.plan_batch(last_image_name="", limit=100)
service.create_move_job(stale_plan_a)
with pytest.raises(ValueError, match="active image move job"):
service.create_move_job(stale_plan_b)
def test_startup_recovery_completes_planned_job_before_any_file_move(tmp_path: Path) -> None:
service, records = _service(tmp_path, strategy="date")
image_name = "image-e.png"
_save_image(service, records, image_name, "", "2024-03-04 05:06:07.000", "purple")
moves = service.plan_batch(last_image_name="", limit=100)
job_id = service.create_move_job(moves)
recovered_once = service.startup_recovery()
recovered_twice = service.startup_recovery()
assert recovered_once.committed == 1
assert recovered_once.errors == 0
assert recovered_twice.committed == 0
assert recovered_twice.errors == 0
assert records.get(image_name).image_subfolder == "2024/03/04"
assert service.get_job(job_id).state == "committed"
assert _job_item_states(service, job_id) == {image_name: "committed"}
def test_background_recovery_can_start_when_journal_job_is_active(tmp_path: Path) -> None:
service, records = _service(tmp_path, strategy="date")
image_name = "image-background-recovery.png"
_save_image(service, records, image_name, "", "2024-03-05 05:06:07.000", "purple")
job_id = service.create_move_job(service.plan_batch(last_image_name="", limit=100))
status = service.start_background_recovery()
assert status.is_running is True
assert status.operation == "recovery"
assert service._future is not None
service._future.result(timeout=5)
assert records.get(image_name).image_subfolder == "2024/03/05"
assert service.get_job(job_id).state == "committed"
def test_start_runs_recovery_before_normal_operation(tmp_path: Path) -> None:
service, records = _service(tmp_path, strategy="date")
image_name = "image-startup-recovery.png"
_save_image(service, records, image_name, "", "2024-03-05 05:06:07.000", "purple")
job_id = service.create_move_job(service.plan_batch(last_image_name="", limit=100))
service.perform_filesystem_moves(job_id)
service.start(MagicMock())
assert records.get(image_name).image_subfolder == "2024/03/05"
assert service.get_job(job_id).state == "committed"
assert service.is_maintenance_active() is False
def test_start_leaves_maintenance_active_when_recovery_remains_incomplete(tmp_path: Path) -> None:
service, records = _service(tmp_path, strategy="date")
image_name = "image-startup-recovery-retry.png"
_save_image(service, records, image_name, "", "2024-03-05 05:06:07.000", "purple")
service.create_move_job(service.plan_batch(last_image_name="", limit=100))
with patch.object(service, "complete_partial_filesystem_moves", side_effect=OSError("temporary failure")):
service.start(MagicMock())
assert records.get(image_name).image_subfolder == ""
assert service.is_maintenance_active() is True
@pytest.mark.parametrize(("pending", "in_progress"), [(1, 0), (0, 1)])
def test_background_move_rejects_active_queue_work(tmp_path: Path, pending: int, in_progress: int) -> None:
service, _records = _service(tmp_path, strategy="date")
invoker = MagicMock()
invoker.services.session_queue.get_queue_status.return_value = SessionQueueStatus(
queue_id=DEFAULT_QUEUE_ID,
item_id=None,
batch_id=None,
session_id=None,
pending=pending,
in_progress=in_progress,
waiting=0,
completed=0,
failed=0,
canceled=0,
total=1,
)
service.start(invoker)
with pytest.raises(ImageMoveQueueActive, match="queue work is active"):
service.start_background_move_all()
def test_background_move_is_reserved_before_queue_check(tmp_path: Path) -> None:
service, _records = _service(tmp_path, strategy="date")
invoker = MagicMock()
def get_queue_status(queue_id: str) -> SessionQueueStatus:
assert queue_id == DEFAULT_QUEUE_ID
assert service.is_maintenance_active() is True
return SessionQueueStatus(
queue_id=DEFAULT_QUEUE_ID,
item_id=None,
batch_id=None,
session_id=None,
pending=1,
in_progress=0,
waiting=0,
completed=0,
failed=0,
canceled=0,
total=1,
)
invoker.services.session_queue.get_queue_status.side_effect = get_queue_status
service.start(invoker)
with pytest.raises(ImageMoveQueueActive, match="queue work is active"):
service.start_background_move_all()
assert service.is_maintenance_active() is False
def test_maintenance_is_active_while_background_job_or_uncommitted_journal_exists(tmp_path: Path) -> None:
service, records = _service(tmp_path, strategy="date")
image_name = "image-maintenance-active.png"
_save_image(service, records, image_name, "", "2024-03-05 05:06:07.000", "purple")
service.create_move_job(service.plan_batch(last_image_name="", limit=100))
assert service.is_maintenance_active() is True
release_worker = threading.Event()
def wait_for_release() -> None:
release_worker.wait(timeout=5)
service._start_background_operation("recovery", wait_for_release)
try:
assert service.is_maintenance_active() is True
finally:
release_worker.set()
assert service._future is not None
service._future.result(timeout=5)
def test_background_worker_error_is_exposed_in_status(tmp_path: Path) -> None:
service, _records = _service(tmp_path, strategy="date")
started_worker = threading.Event()
release_worker = threading.Event()
def raise_error() -> None:
started_worker.set()
release_worker.wait(timeout=5)
raise RuntimeError("background failed")
status = service._start_background_operation("move_all", raise_error)
assert started_worker.wait(timeout=5) is True
assert status.is_running is True
assert service._future is not None
release_worker.set()
service._future.result(timeout=5)
status = service.get_background_status()
assert status.is_running is False
assert status.operation is None
assert status.last_error == "background failed"
def test_stop_waits_for_active_background_job_without_recording_error(tmp_path: Path) -> None:
service, records = _service(tmp_path, strategy="date")
image_name = "image-background-stop.png"
_save_image(service, records, image_name, "", "2024-03-05 05:06:07.000", "purple")
job_id = service.create_move_job(service.plan_batch(last_image_name="", limit=100))
release_worker = threading.Event()
def wait_for_shutdown() -> None:
release_worker.wait(timeout=5)
service._start_background_operation("recovery", wait_for_shutdown)
stop_thread = threading.Thread(target=service.stop)
stop_thread.start()
assert stop_thread.is_alive()
release_worker.set()
stop_thread.join(timeout=5)
assert not stop_thread.is_alive()
assert service.get_job(job_id).error_message is None
assert service.get_background_status().last_error is None
def test_startup_recovery_completes_partial_multi_image_move(tmp_path: Path) -> None:
service, records = _service(tmp_path, strategy="date")
_save_image(service, records, "image-f.png", "", "2024-04-05 06:07:08.000", "orange")
_save_image(service, records, "image-g.png", "", "2024-04-06 06:07:08.000", "cyan")
moves = service.plan_batch(last_image_name="", limit=100)
job_id = service.create_move_job(moves)
first_move = moves[0]
first_move.new_path.parent.mkdir(parents=True, exist_ok=True)
first_move.new_thumbnail_path.parent.mkdir(parents=True, exist_ok=True)
first_move.old_path.replace(first_move.new_path)
first_move.old_thumbnail_path.replace(first_move.new_thumbnail_path)
recovered_once = service.startup_recovery()
recovered_twice = service.startup_recovery()
assert recovered_once.committed == 2
assert recovered_once.errors == 0
assert recovered_twice.committed == 0
assert recovered_twice.errors == 0
assert records.get("image-f.png").image_subfolder == "2024/04/05"
assert records.get("image-g.png").image_subfolder == "2024/04/06"
assert service.get_job(job_id).state == "committed"
assert _job_item_states(service, job_id) == {"image-f.png": "committed", "image-g.png": "committed"}
def test_startup_recovery_marks_committed_after_db_update_but_before_journal_commit(tmp_path: Path) -> None:
service, records = _service(tmp_path, strategy="date")
image_name = "image-h.png"
_save_image(service, records, image_name, "", "2024-05-06 07:08:09.000", "pink")
moves = service.plan_batch(last_image_name="", limit=100)
job_id = service.create_move_job(moves)
service.perform_filesystem_moves(job_id)
with service._db.transaction() as cursor:
cursor.execute(
"UPDATE images SET image_subfolder = ? WHERE image_name = ?;",
("2024/05/06", image_name),
)
recovered_once = service.startup_recovery()
recovered_twice = service.startup_recovery()
assert recovered_once.committed == 1
assert recovered_once.errors == 0
assert recovered_twice.committed == 0
assert recovered_twice.errors == 0
assert records.get(image_name).image_subfolder == "2024/05/06"
assert service.get_job(job_id).state == "committed"
assert _job_item_states(service, job_id) == {image_name: "committed"}
def test_startup_recovery_marks_error_when_both_old_and_new_full_size_files_exist(tmp_path: Path) -> None:
service, records = _service(tmp_path, strategy="date")
image_name = "image-i.png"
_save_image(service, records, image_name, "", "2024-07-08 09:10:11.000", "red")
moves = service.plan_batch(last_image_name="", limit=100)
job_id = service.create_move_job(moves)
move = moves[0]
move.new_path.parent.mkdir(parents=True, exist_ok=True)
copy2(move.old_path, move.new_path)
recovered = service.startup_recovery()
assert recovered.committed == 0
assert recovered.errors == 1
assert records.get(image_name).image_subfolder == ""
assert service.get_job(job_id).state == "error"
assert _job_item_states(service, job_id) == {image_name: "error"}
def test_startup_recovery_marks_error_when_neither_old_nor_new_full_size_file_exists(tmp_path: Path) -> None:
service, records = _service(tmp_path, strategy="date")
image_name = "image-j.png"
_save_image(service, records, image_name, "", "2024-08-09 10:11:12.000", "blue")
moves = service.plan_batch(last_image_name="", limit=100)
job_id = service.create_move_job(moves)
moves[0].old_path.unlink()
recovered = service.startup_recovery()
assert recovered.committed == 0
assert recovered.errors == 1
assert records.get(image_name).image_subfolder == ""
assert service.get_job(job_id).state == "error"
assert _job_item_states(service, job_id) == {image_name: "error"}
def test_startup_recovery_keeps_job_recoverable_after_ordinary_exception(tmp_path: Path) -> None:
service, records = _service(tmp_path, strategy="date")
image_name = "image-k.png"
_save_image(service, records, image_name, "", "2024-09-10 11:12:13.000", "white")
job_id = service.create_move_job(service.plan_batch(last_image_name="", limit=100))
with patch.object(service, "complete_partial_filesystem_moves", side_effect=OSError("temporary failure")):
recovered = service.startup_recovery()
assert recovered.committed == 0
assert recovered.errors == 1
job = service.get_job(job_id)
assert job.state == "planned"
assert job.error_message == "temporary failure"
recovered_retry = service.startup_recovery()
assert recovered_retry.committed == 1
assert recovered_retry.errors == 0
assert records.get(image_name).image_subfolder == "2024/09/10"
assert service.get_job(job_id).state == "committed"
def test_startup_recovery_regenerates_thumbnail_when_old_and_new_thumbnails_exist(tmp_path: Path) -> None:
service, records = _service(tmp_path, strategy="date")
image_name = "image-l.png"
_save_image(service, records, image_name, "", "2024-10-11 12:13:14.000", "black")
moves = service.plan_batch(last_image_name="", limit=100)
job_id = service.create_move_job(moves)
move = moves[0]
move.new_path.parent.mkdir(parents=True, exist_ok=True)
move.new_thumbnail_path.parent.mkdir(parents=True, exist_ok=True)
move.old_path.replace(move.new_path)
copy2(move.old_thumbnail_path, move.new_thumbnail_path)
recovered = service.startup_recovery()
assert recovered.committed == 1
assert recovered.errors == 0
assert records.get(image_name).image_subfolder == "2024/10/11"
assert move.new_thumbnail_path.exists()
assert not move.old_thumbnail_path.exists()
assert service.get_job(job_id).state == "committed"
def test_preflight_rejects_duplicate_thumbnail_destination_paths(tmp_path: Path) -> None:
service, records = _service(tmp_path, strategy="date")
_save_image(service, records, "same-name.jpg", "", "2024-12-13 14:15:16.000", "red")
_save_image(service, records, "same-name.png", "", "2024-12-13 14:15:16.000", "green")
with pytest.raises(ValueError, match="Duplicate destination thumbnail path"):
service.plan_batch(last_image_name="", limit=100)
def test_successful_filesystem_move_fsyncs_files_and_directories(tmp_path: Path) -> None:
service, records = _service(tmp_path, strategy="date")
image_name = "image-m.png"
_save_image(service, records, image_name, "", "2025-01-02 03:04:05.000", "blue")
job_id = service.create_move_job(service.plan_batch(last_image_name="", limit=100))
with (
patch.object(service, "_fsync_file") as fsync_file,
patch.object(service, "_fsync_dir") as fsync_dir,
):
service.perform_filesystem_moves(job_id)
moved = service._get_items(job_id)[0]
fsync_file.assert_any_call(moved.new_path)
fsync_file.assert_any_call(moved.new_thumbnail_path)
fsync_dir.assert_any_call(moved.new_path.parent)
fsync_dir.assert_any_call(moved.old_path.parent)
fsync_dir.assert_any_call(moved.new_thumbnail_path.parent)
fsync_dir.assert_any_call(moved.old_thumbnail_path.parent)
def test_fsync_dir_ignores_platform_close_failures(tmp_path: Path) -> None:
service, _records = _service(tmp_path, strategy="date")
with (
patch("invokeai.app.services.image_moves.image_moves_default.os.open", return_value=123),
patch(
"invokeai.app.services.image_moves.image_moves_default.os.fsync",
side_effect=OSError(9, "Bad file descriptor"),
),
patch(
"invokeai.app.services.image_moves.image_moves_default.os.close",
side_effect=OSError(9, "Bad file descriptor"),
),
):
service._fsync_dir(tmp_path)
def test_fsync_file_ignores_platform_fsync_failures(tmp_path: Path) -> None:
service, _records = _service(tmp_path, strategy="date")
path = tmp_path / "image.png"
path.write_bytes(b"test")
with patch(
"invokeai.app.services.image_moves.image_moves_default.os.fsync",
side_effect=OSError(9, "Bad file descriptor"),
):
service._fsync_file(path)
@@ -0,0 +1,100 @@
"""DB-backed tests for SqliteImageRecordStorage.
Verifies that image_subfolder round-trips correctly through save(), get(),
get_many(), and delete_intermediates() against a real (in-memory) SQLite database.
"""
import pytest
from invokeai.app.services.config.config_default import InvokeAIAppConfig
from invokeai.app.services.image_records.image_records_common import ImageCategory, ResourceOrigin
from invokeai.app.services.image_records.image_records_sqlite import SqliteImageRecordStorage
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
from invokeai.backend.util.logging import InvokeAILogger
from tests.fixtures.sqlite_database import create_mock_sqlite_database
@pytest.fixture
def store() -> SqliteImageRecordStorage:
config = InvokeAIAppConfig(use_memory_db=True)
logger = InvokeAILogger.get_logger(config=config)
db = create_mock_sqlite_database(config, logger)
return SqliteImageRecordStorage(db=db)
def _save(store: SqliteImageRecordStorage, name: str, subfolder: str = "", is_intermediate: bool = False) -> None:
store.save(
image_name=name,
image_origin=ResourceOrigin.INTERNAL,
image_category=ImageCategory.GENERAL,
width=64,
height=64,
has_workflow=False,
is_intermediate=is_intermediate,
image_subfolder=subfolder,
)
class TestImageSubfolderRoundTrip:
"""save() -> get() preserves image_subfolder."""
def test_default_empty_subfolder(self, store: SqliteImageRecordStorage) -> None:
_save(store, "img_default.png")
record = store.get("img_default.png")
assert record.image_subfolder == ""
def test_custom_subfolder(self, store: SqliteImageRecordStorage) -> None:
_save(store, "img_sub.png", subfolder="2026/04/11")
record = store.get("img_sub.png")
assert record.image_subfolder == "2026/04/11"
def test_nested_subfolder(self, store: SqliteImageRecordStorage) -> None:
_save(store, "img_nested.png", subfolder="a/b/c/d")
record = store.get("img_nested.png")
assert record.image_subfolder == "a/b/c/d"
class TestGetManySubfolder:
"""get_many() deserializes image_subfolder for every row."""
def test_get_many_returns_subfolders(self, store: SqliteImageRecordStorage) -> None:
_save(store, "flat.png", subfolder="")
_save(store, "dated.png", subfolder="2026/01")
_save(store, "hashed.png", subfolder="ab")
result = store.get_many(limit=10, order_dir=SQLiteDirection.Ascending)
by_name = {r.image_name: r.image_subfolder for r in result.items}
assert by_name["flat.png"] == ""
assert by_name["dated.png"] == "2026/01"
assert by_name["hashed.png"] == "ab"
class TestDeleteIntermediatesSubfolder:
"""delete_intermediates() returns (name, subfolder) pairs and removes rows."""
def test_returns_subfolder_pairs(self, store: SqliteImageRecordStorage) -> None:
_save(store, "keep.png", subfolder="general", is_intermediate=False)
_save(store, "tmp1.png", subfolder="intermediate", is_intermediate=True)
_save(store, "tmp2.png", subfolder="intermediate", is_intermediate=True)
pairs = store.delete_intermediates()
# Should return only intermediate images with their subfolders
assert len(pairs) == 2
names_and_subs = set(pairs)
assert ("tmp1.png", "intermediate") in names_and_subs
assert ("tmp2.png", "intermediate") in names_and_subs
# Non-intermediate image should still exist
record = store.get("keep.png")
assert record.image_subfolder == "general"
def test_intermediates_are_deleted(self, store: SqliteImageRecordStorage) -> None:
_save(store, "tmp.png", subfolder="x", is_intermediate=True)
store.delete_intermediates()
from invokeai.app.services.image_records.image_records_common import ImageRecordNotFoundException
with pytest.raises(ImageRecordNotFoundException):
store.get("tmp.png")
@@ -0,0 +1,264 @@
"""Tests for ImageService (images_default.py).
Covers subfolder forwarding for all strategies and the delete_images_on_board
silent-failure contract (Points 2 & 3 from PR review).
"""
from unittest.mock import MagicMock
import pytest
from PIL import Image
from invokeai.app.services.image_records.image_records_common import (
ImageCategory,
ImageRecord,
ResourceOrigin,
)
from invokeai.app.services.images.images_default import ImageService
from invokeai.app.util.misc import get_iso_timestamp
@pytest.fixture
def image_service() -> ImageService:
svc = ImageService()
invoker = MagicMock()
# Wire up service references
invoker.services.names.create_image_name.return_value = "abc12345-test.png"
invoker.services.image_records.get.return_value = _make_record(image_subfolder="some/sub")
invoker.services.board_image_records.get_board_for_image.return_value = None
invoker.services.urls.get_image_url.return_value = "http://localhost/img.png"
invoker.services.configuration.image_subfolder_strategy = "flat"
svc.start(invoker)
return svc
def _make_record(
image_name: str = "abc12345-test.png",
image_subfolder: str = "",
is_intermediate: bool = False,
) -> ImageRecord:
now = get_iso_timestamp()
return ImageRecord(
image_name=image_name,
image_origin=ResourceOrigin.INTERNAL,
image_category=ImageCategory.GENERAL,
width=64,
height=64,
created_at=now,
updated_at=now,
is_intermediate=is_intermediate,
starred=False,
has_workflow=False,
image_subfolder=image_subfolder,
)
# ── Point 2: subfolder forwarding tests ──
class TestCreateSubfolderForwarding:
"""Verify that create() computes and forwards the correct subfolder for each strategy."""
@pytest.mark.parametrize(
"strategy_name,expected_subfolder",
[
("flat", ""),
("type", "general"),
("hash", "ab"), # first 2 chars of "abc12345-test.png"
],
ids=["flat", "type", "hash"],
)
def test_create_forwards_subfolder(self, image_service: ImageService, strategy_name: str, expected_subfolder: str):
invoker = image_service._ImageService__invoker # type: ignore
invoker.services.configuration.image_subfolder_strategy = strategy_name
# Make get_dto work by returning a record with the expected subfolder
invoker.services.image_records.get.return_value = _make_record(image_subfolder=expected_subfolder)
image = Image.new("RGB", (64, 64))
image_service.create(
image=image,
image_origin=ResourceOrigin.INTERNAL,
image_category=ImageCategory.GENERAL,
)
# Assert image_records.save was called with the right subfolder
save_call = invoker.services.image_records.save
save_call.assert_called_once()
assert save_call.call_args.kwargs["image_subfolder"] == expected_subfolder
# Assert image_files.save was called with the same subfolder
file_save = invoker.services.image_files.save
file_save.assert_called_once()
assert file_save.call_args.kwargs["image_subfolder"] == expected_subfolder
def test_create_date_strategy_produces_date_subfolder(self, image_service: ImageService):
invoker = image_service._ImageService__invoker # type: ignore
invoker.services.configuration.image_subfolder_strategy = "date"
invoker.services.image_records.get.return_value = _make_record(image_subfolder="2026/04/05")
image = Image.new("RGB", (64, 64))
image_service.create(
image=image,
image_origin=ResourceOrigin.INTERNAL,
image_category=ImageCategory.GENERAL,
)
subfolder = invoker.services.image_records.save.call_args.kwargs["image_subfolder"]
# Date strategy should produce YYYY/MM/DD format
parts = subfolder.split("/")
assert len(parts) == 3
assert all(p.isdigit() for p in parts)
def test_create_type_strategy_intermediate(self, image_service: ImageService):
invoker = image_service._ImageService__invoker # type: ignore
invoker.services.configuration.image_subfolder_strategy = "type"
invoker.services.image_records.get.return_value = _make_record(image_subfolder="intermediate")
image = Image.new("RGB", (64, 64))
image_service.create(
image=image,
image_origin=ResourceOrigin.INTERNAL,
image_category=ImageCategory.GENERAL,
is_intermediate=True,
)
subfolder = invoker.services.image_records.save.call_args.kwargs["image_subfolder"]
assert subfolder == "intermediate"
class TestReadOperationsForwardSubfolder:
"""Verify that read operations look up the record and forward image_subfolder."""
def test_get_pil_image(self, image_service: ImageService):
invoker = image_service._ImageService__invoker # type: ignore
invoker.services.image_records.get.return_value = _make_record(image_subfolder="2026/01/01")
image_service.get_pil_image("test.png")
invoker.services.image_files.get.assert_called_once_with("test.png", image_subfolder="2026/01/01")
def test_get_workflow(self, image_service: ImageService):
invoker = image_service._ImageService__invoker # type: ignore
invoker.services.image_records.get.return_value = _make_record(image_subfolder="general")
image_service.get_workflow("test.png")
invoker.services.image_files.get_workflow.assert_called_once_with("test.png", image_subfolder="general")
def test_get_graph(self, image_service: ImageService):
invoker = image_service._ImageService__invoker # type: ignore
invoker.services.image_records.get.return_value = _make_record(image_subfolder="general")
image_service.get_graph("test.png")
invoker.services.image_files.get_graph.assert_called_once_with("test.png", image_subfolder="general")
def test_get_path(self, image_service: ImageService):
invoker = image_service._ImageService__invoker # type: ignore
invoker.services.image_records.get.return_value = _make_record(image_subfolder="ab")
image_service.get_path("test.png")
invoker.services.image_files.get_path.assert_called_once_with("test.png", False, image_subfolder="ab")
def test_get_path_thumbnail(self, image_service: ImageService):
invoker = image_service._ImageService__invoker # type: ignore
invoker.services.image_records.get.return_value = _make_record(image_subfolder="ab")
image_service.get_path("test.png", thumbnail=True)
invoker.services.image_files.get_path.assert_called_once_with("test.png", True, image_subfolder="ab")
class TestDeleteForwardsSubfolder:
"""Verify that delete operations forward image_subfolder."""
def test_delete_forwards_subfolder(self, image_service: ImageService):
invoker = image_service._ImageService__invoker # type: ignore
invoker.services.image_records.get.return_value = _make_record(image_subfolder="2026/04/05")
image_service.delete("test.png")
invoker.services.image_files.delete.assert_called_once_with("test.png", image_subfolder="2026/04/05")
invoker.services.image_records.delete.assert_called_once_with("test.png")
def test_delete_intermediates_forwards_subfolder(self, image_service: ImageService):
invoker = image_service._ImageService__invoker # type: ignore
invoker.services.image_records.delete_intermediates.return_value = [
("img1.png", "intermediate"),
("img2.png", "intermediate"),
]
count = image_service.delete_intermediates()
assert count == 2
calls = invoker.services.image_files.delete.call_args_list
assert calls[0].args == ("img1.png",)
assert calls[0].kwargs == {"image_subfolder": "intermediate"}
assert calls[1].args == ("img2.png",)
assert calls[1].kwargs == {"image_subfolder": "intermediate"}
# ── Point 3: delete_images_on_board silent-failure contract ──
class TestDeleteImagesOnBoardContract:
"""Tests for the silent-failure behavior of delete_images_on_board."""
def test_db_rows_deleted_even_when_file_delete_fails(self, image_service: ImageService):
"""Current behavior: DB rows are deleted even if file cleanup fails for some images.
This test documents the contract so any change is intentional."""
invoker = image_service._ImageService__invoker # type: ignore
invoker.services.board_image_records.get_all_board_image_names_for_board.return_value = [
"good.png",
"bad.png",
]
# First image record lookup succeeds, second fails
good_record = _make_record(image_name="good.png", image_subfolder="general")
bad_record = _make_record(image_name="bad.png", image_subfolder="bad/path")
invoker.services.image_records.get.side_effect = [good_record, bad_record]
# File delete succeeds for first, fails for second
invoker.services.image_files.delete.side_effect = [None, Exception("disk error")]
image_service.delete_images_on_board("board-1")
# DB rows are still deleted for all images
invoker.services.image_records.delete_many.assert_called_once_with(["good.png", "bad.png"])
def test_file_cleanup_failure_does_not_raise(self, image_service: ImageService):
"""File cleanup errors are swallowed, not propagated."""
invoker = image_service._ImageService__invoker # type: ignore
invoker.services.board_image_records.get_all_board_image_names_for_board.return_value = ["img.png"]
record = _make_record(image_name="img.png", image_subfolder="sub")
invoker.services.image_records.get.return_value = record
invoker.services.image_files.delete.side_effect = Exception("permission denied")
# Should not raise
image_service.delete_images_on_board("board-1")
# DB delete still happens
invoker.services.image_records.delete_many.assert_called_once()
def test_record_lookup_failure_does_not_block_others(self, image_service: ImageService):
"""If getting the record for one image fails, other images are still processed."""
invoker = image_service._ImageService__invoker # type: ignore
invoker.services.board_image_records.get_all_board_image_names_for_board.return_value = [
"missing.png",
"ok.png",
]
ok_record = _make_record(image_name="ok.png", image_subfolder="")
invoker.services.image_records.get.side_effect = [Exception("not found"), ok_record]
image_service.delete_images_on_board("board-1")
# File delete was attempted for the second image only
invoker.services.image_files.delete.assert_called_once_with("ok.png", image_subfolder="")
# DB rows are still deleted for all
invoker.services.image_records.delete_many.assert_called_once_with(["missing.png", "ok.png"])
@@ -0,0 +1,220 @@
"""
Tests for missing model detection (_scan_for_missing_models) and bulk deletion.
"""
import gc
from pathlib import Path
import pytest
from invokeai.app.services.config import InvokeAIAppConfig
from invokeai.app.services.model_install import ModelInstallServiceBase
from invokeai.app.services.model_records import UnknownModelException
from invokeai.backend.model_manager.configs.textual_inversion import TI_File_SD1_Config
from invokeai.backend.model_manager.taxonomy import (
BaseModelType,
ModelFormat,
ModelSourceType,
ModelType,
)
from tests.backend.model_manager.model_manager_fixtures import * # noqa F403
class TestScanForMissingModels:
"""Tests for ModelInstallService._scan_for_missing_models()."""
def test_no_missing_models(
self, mm2_installer: ModelInstallServiceBase, embedding_file: Path, mm2_app_config: InvokeAIAppConfig
) -> None:
"""When all registered models exist on disk, _scan_for_missing_models returns an empty list."""
mm2_installer.register_path(embedding_file)
missing = mm2_installer._scan_for_missing_models()
assert len(missing) == 0
def test_detects_missing_model(
self, mm2_installer: ModelInstallServiceBase, embedding_file: Path, mm2_app_config: InvokeAIAppConfig
) -> None:
"""A model whose path does not exist on disk is reported as missing."""
# Register a real model first, then add a fake one with a non-existent path
mm2_installer.register_path(embedding_file)
fake_config = TI_File_SD1_Config(
key="missing-model-key-1",
path="/nonexistent/path/missing_model.safetensors",
name="MissingModel",
base=BaseModelType.StableDiffusion1,
type=ModelType.TextualInversion,
format=ModelFormat.EmbeddingFile,
hash="FAKEHASH1",
file_size=1024,
source="test/source",
source_type=ModelSourceType.Path,
)
mm2_installer.record_store.add_model(fake_config)
missing = mm2_installer._scan_for_missing_models()
assert len(missing) == 1
assert missing[0].key == "missing-model-key-1"
def test_mix_of_existing_and_missing(
self,
mm2_installer: ModelInstallServiceBase,
embedding_file: Path,
diffusers_dir: Path,
mm2_app_config: InvokeAIAppConfig,
) -> None:
"""With multiple models, only the ones with missing files are returned."""
key_existing = mm2_installer.register_path(embedding_file)
mm2_installer.register_path(diffusers_dir)
# Add two models with non-existent paths
fake1 = TI_File_SD1_Config(
key="missing-key-1",
path="/nonexistent/missing1.safetensors",
name="Missing1",
base=BaseModelType.StableDiffusion1,
type=ModelType.TextualInversion,
format=ModelFormat.EmbeddingFile,
hash="FAKEHASH_A",
file_size=1024,
source="test/source1",
source_type=ModelSourceType.Path,
)
fake2 = TI_File_SD1_Config(
key="missing-key-2",
path="/nonexistent/missing2.safetensors",
name="Missing2",
base=BaseModelType.StableDiffusion1,
type=ModelType.TextualInversion,
format=ModelFormat.EmbeddingFile,
hash="FAKEHASH_B",
file_size=2048,
source="test/source2",
source_type=ModelSourceType.Path,
)
mm2_installer.record_store.add_model(fake1)
mm2_installer.record_store.add_model(fake2)
missing = mm2_installer._scan_for_missing_models()
missing_keys = {m.key for m in missing}
assert len(missing) == 2
assert "missing-key-1" in missing_keys
assert "missing-key-2" in missing_keys
assert key_existing not in missing_keys
def test_empty_store_returns_empty(self, mm2_installer: ModelInstallServiceBase) -> None:
"""With no models registered, _scan_for_missing_models returns an empty list."""
missing = mm2_installer._scan_for_missing_models()
assert len(missing) == 0
class TestBulkDelete:
"""Tests for bulk model deletion."""
def test_delete_installed_model(
self, mm2_installer: ModelInstallServiceBase, embedding_file: Path, mm2_app_config: InvokeAIAppConfig
) -> None:
"""Deleting an installed model removes it from the store and disk."""
key = mm2_installer.install_path(embedding_file)
record = mm2_installer.record_store.get_model(key)
model_path = mm2_app_config.models_path / record.path
assert model_path.exists()
assert mm2_installer.record_store.exists(key)
gc.collect()
mm2_installer.delete(key)
with pytest.raises(UnknownModelException):
mm2_installer.record_store.get_model(key)
def test_unregister_missing_model(
self, mm2_installer: ModelInstallServiceBase, mm2_app_config: InvokeAIAppConfig
) -> None:
"""Unregistering a model whose file is missing removes it from the DB."""
fake_config = TI_File_SD1_Config(
key="missing-to-delete",
path="/nonexistent/path/gone.safetensors",
name="GoneModel",
base=BaseModelType.StableDiffusion1,
type=ModelType.TextualInversion,
format=ModelFormat.EmbeddingFile,
hash="FAKEHASH_GONE",
file_size=1024,
source="test/source",
source_type=ModelSourceType.Path,
)
mm2_installer.record_store.add_model(fake_config)
assert mm2_installer.record_store.exists("missing-to-delete")
# Unregister removes it from DB without touching disk
mm2_installer.unregister("missing-to-delete")
with pytest.raises(UnknownModelException):
mm2_installer.record_store.get_model("missing-to-delete")
def test_delete_unknown_key_raises(self, mm2_installer: ModelInstallServiceBase) -> None:
"""Deleting a model with an unknown key raises UnknownModelException."""
with pytest.raises(UnknownModelException):
mm2_installer.delete("nonexistent-key-12345")
def test_scan_then_unregister_clears_missing(
self, mm2_installer: ModelInstallServiceBase, mm2_app_config: InvokeAIAppConfig
) -> None:
"""After unregistering all missing models, _scan_for_missing_models returns empty."""
# Add two models with non-existent paths
for i in range(2):
config = TI_File_SD1_Config(
key=f"missing-bulk-{i}",
path=f"/nonexistent/bulk_{i}.safetensors",
name=f"BulkMissing{i}",
base=BaseModelType.StableDiffusion1,
type=ModelType.TextualInversion,
format=ModelFormat.EmbeddingFile,
hash=f"BULKHASH{i}",
file_size=1024,
source=f"test/bulk{i}",
source_type=ModelSourceType.Path,
)
mm2_installer.record_store.add_model(config)
missing = mm2_installer._scan_for_missing_models()
assert len(missing) == 2
# Unregister all missing (simulates bulk delete for missing models)
for model in missing:
mm2_installer.unregister(model.key)
assert len(mm2_installer._scan_for_missing_models()) == 0
def test_bulk_unregister_does_not_affect_existing_models(
self,
mm2_installer: ModelInstallServiceBase,
embedding_file: Path,
mm2_app_config: InvokeAIAppConfig,
) -> None:
"""Unregistering missing models does not affect models that exist on disk."""
existing_key = mm2_installer.register_path(embedding_file)
fake_config = TI_File_SD1_Config(
key="missing-selective",
path="/nonexistent/selective.safetensors",
name="SelectiveMissing",
base=BaseModelType.StableDiffusion1,
type=ModelType.TextualInversion,
format=ModelFormat.EmbeddingFile,
hash="SELECTIVEHASH",
file_size=1024,
source="test/selective",
source_type=ModelSourceType.Path,
)
mm2_installer.record_store.add_model(fake_config)
# Only unregister the missing one
missing = mm2_installer._scan_for_missing_models()
assert len(missing) == 1
for model in missing:
mm2_installer.unregister(model.key)
# Existing model should still be there
assert mm2_installer.record_store.exists(existing_key)
assert len(mm2_installer._scan_for_missing_models()) == 0
@@ -0,0 +1,600 @@
"""
Test the model installer
"""
import gc
import platform
import shutil
import threading
import time
import uuid
from pathlib import Path
from typing import Any, Dict
import pytest
from pydantic_core import Url
from invokeai.app.services.config import InvokeAIAppConfig
from invokeai.app.services.events.events_base import EventServiceBase
from invokeai.app.services.events.events_common import (
ModelInstallCompleteEvent,
ModelInstallDownloadProgressEvent,
ModelInstallDownloadsCompleteEvent,
ModelInstallDownloadStartedEvent,
ModelInstallErrorEvent,
ModelInstallStartedEvent,
)
from invokeai.app.services.model_install import (
HFModelSource,
ModelInstallService,
ModelInstallServiceBase,
)
from invokeai.app.services.model_install.model_install_common import (
InstallStatus,
InvalidModelConfigException,
LocalModelSource,
ModelInstallJob,
URLModelSource,
)
from invokeai.app.services.model_records import ModelRecordChanges, UnknownModelException
from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig
from invokeai.backend.model_manager.taxonomy import (
BaseModelType,
ModelFormat,
ModelRepoVariant,
ModelSourceType,
ModelType,
)
from tests.backend.model_manager.model_manager_fixtures import * # noqa F403
from tests.test_nodes import TestEventService
OS = platform.uname().system
def test_registration(mm2_installer: ModelInstallServiceBase, embedding_file: Path) -> None:
store = mm2_installer.record_store
matches = store.search_by_attr(model_name="test_embedding")
assert len(matches) == 0
key = mm2_installer.register_path(embedding_file)
# Not raising here is sufficient - key should be UUIDv4
uuid.UUID(key, version=4)
def test_registration_meta(mm2_installer: ModelInstallServiceBase, embedding_file: Path) -> None:
store = mm2_installer.record_store
key = mm2_installer.register_path(embedding_file)
model_record = store.get_model(key)
assert model_record is not None
assert model_record.name == "test_embedding"
assert model_record.type == ModelType.TextualInversion
assert Path(model_record.path) == embedding_file
assert Path(model_record.path).exists()
assert model_record.base == BaseModelType("sd-1")
assert model_record.description is None
assert model_record.source is not None
assert Path(model_record.source) == embedding_file
def test_registration_meta_override_fail(mm2_installer: ModelInstallServiceBase, embedding_file: Path) -> None:
with pytest.raises(InvalidModelConfigException):
mm2_installer.register_path(embedding_file, ModelRecordChanges(name="banana_sushi", type=ModelType("lora")))
def test_registration_meta_override_succeed(mm2_installer: ModelInstallServiceBase, embedding_file: Path) -> None:
store = mm2_installer.record_store
key = mm2_installer.register_path(
embedding_file, ModelRecordChanges(name="banana_sushi", source="fake/repo_id", key="xyzzy")
)
model_record = store.get_model(key)
assert model_record.name == "banana_sushi"
assert model_record.source == "fake/repo_id"
assert model_record.key == "xyzzy"
def test_install(
mm2_installer: ModelInstallServiceBase, embedding_file: Path, mm2_app_config: InvokeAIAppConfig
) -> None:
store = mm2_installer.record_store
key = mm2_installer.install_path(embedding_file)
model_record = store.get_model(key)
assert model_record.path.endswith(f"{key}/test_embedding.safetensors")
assert (mm2_app_config.models_path / model_record.path).exists()
assert model_record.source == embedding_file.as_posix()
def test_rename(
mm2_installer: ModelInstallServiceBase, embedding_file: Path, mm2_app_config: InvokeAIAppConfig
) -> None:
store = mm2_installer.record_store
key = mm2_installer.install_path(embedding_file)
model_record = store.get_model(key)
assert model_record.path.endswith(f"{key}/test_embedding.safetensors")
new_model_record = store.update_model(
key,
ModelRecordChanges(name="new model name", base=BaseModelType.StableDiffusion2),
allow_class_change=True,
)
# Renaming the model record shouldn't rename the file
assert new_model_record.name == "new model name"
assert model_record.path.endswith(f"{key}/test_embedding.safetensors")
@pytest.mark.parametrize(
"fixture_name,size,key,destination",
[
("embedding_file", 15440, "foo", "foo/test_embedding.safetensors"),
("diffusers_dir", 8241 if OS == "Windows" else 7907, "bar", "bar"), # EOL chars
],
)
def test_background_install(
mm2_installer: ModelInstallServiceBase,
fixture_name: str,
key: str,
size: int,
destination: str,
mm2_app_config: InvokeAIAppConfig,
request: pytest.FixtureRequest,
) -> None:
"""Note: may want to break this down into several smaller unit tests."""
path: Path = request.getfixturevalue(fixture_name)
description = "Test of metadata assignment"
source = LocalModelSource(path=path, inplace=False)
job = mm2_installer.import_model(source, config=ModelRecordChanges(key=key, description=description))
assert job is not None
assert isinstance(job, ModelInstallJob)
# See if job is registered properly
assert job in mm2_installer.get_job_by_source(source)
# test that the job object tracked installation correctly
jobs = mm2_installer.wait_for_installs()
assert len(jobs) > 0
my_job = [x for x in jobs if x.source == source]
assert len(my_job) == 1
assert job == my_job[0]
assert job.status == InstallStatus.COMPLETED
assert job.total_bytes == size
# test that the expected events were issued
bus: TestEventService = mm2_installer.event_bus
assert bus
assert hasattr(bus, "events")
assert len(bus.events) == 2
assert isinstance(bus.events[0], ModelInstallStartedEvent)
assert isinstance(bus.events[1], ModelInstallCompleteEvent)
assert Path(bus.events[0].source.path) == source
assert Path(bus.events[1].source.path) == source
key = bus.events[1].key
assert key is not None
# see if the thing actually got installed at the expected location
model_record = mm2_installer.record_store.get_model(key)
assert model_record is not None
assert model_record.path.endswith(destination)
assert (mm2_app_config.models_path / model_record.path).exists()
# see if metadata was properly passed through
assert model_record.description == description
# see if job filtering works
assert mm2_installer.get_job_by_source(source)[0] == job
# see if prune works properly
mm2_installer.prune_jobs()
assert not mm2_installer.get_job_by_source(source)
def test_not_inplace_install(
mm2_installer: ModelInstallServiceBase, embedding_file: Path, mm2_app_config: InvokeAIAppConfig
) -> None:
# An non in-place install will/should call `register_path()` internally
source = LocalModelSource(path=embedding_file, inplace=False)
job = mm2_installer.import_model(source)
mm2_installer.wait_for_installs()
assert job is not None
assert job.config_out is not None
# Non in-place install should _move_ the model from the original location to the models path
# The model config's path should be different from the original file
assert Path(job.config_out.path) != embedding_file
# Original file should _not_ exist after install
assert not embedding_file.exists()
assert (mm2_app_config.models_path / job.config_out.path).exists()
def test_inplace_install(
mm2_installer: ModelInstallServiceBase, embedding_file: Path, mm2_app_config: InvokeAIAppConfig
) -> None:
# An in-place install will/should call `install_path()` internally
source = LocalModelSource(path=embedding_file, inplace=True)
job = mm2_installer.import_model(source)
mm2_installer.wait_for_installs()
assert job is not None
assert job.config_out is not None
# In-place install should not touch the model file, just register it
# The model config's path should be the same as the original file
assert Path(job.config_out.path) == embedding_file
# Model file should still exist after install
assert embedding_file.exists()
assert Path(job.config_out.path).exists()
def test_external_install(mm2_installer: ModelInstallServiceBase) -> None:
config = ModelRecordChanges(name="ChatGPT Image", description="External model", key="chatgpt_image")
job = mm2_installer.heuristic_import("external://openai/gpt-image-1", config=config)
mm2_installer.wait_for_installs()
assert job.status == InstallStatus.COMPLETED
assert job.config_out is not None
assert isinstance(job.config_out, ExternalApiModelConfig)
assert job.config_out.provider_id == "openai"
assert job.config_out.provider_model_id == "gpt-image-1"
assert job.config_out.base == BaseModelType.External
assert job.config_out.type == ModelType.ExternalImageGenerator
assert job.config_out.source_type == ModelSourceType.External
def test_external_install_is_idempotent(mm2_installer: ModelInstallServiceBase) -> None:
first_job = mm2_installer.heuristic_import(
"external://openai/gpt-image-1",
config=ModelRecordChanges(name="Initial name"),
)
mm2_installer.wait_for_installs()
second_job = mm2_installer.heuristic_import(
"external://openai/gpt-image-1",
config=ModelRecordChanges(name="Updated name"),
)
mm2_installer.wait_for_installs()
assert first_job.status == InstallStatus.COMPLETED
assert second_job.status == InstallStatus.COMPLETED
assert first_job.config_out is not None
assert second_job.config_out is not None
assert first_job.config_out.key == second_job.config_out.key
external_models = mm2_installer.record_store.search_by_attr(
base_model=BaseModelType.External,
model_type=ModelType.ExternalImageGenerator,
)
assert len(external_models) == 1
assert isinstance(external_models[0], ExternalApiModelConfig)
assert external_models[0].name == "Updated name"
def test_delete_install(
mm2_installer: ModelInstallServiceBase, embedding_file: Path, mm2_app_config: InvokeAIAppConfig
) -> None:
store = mm2_installer.record_store
key = mm2_installer.install_path(embedding_file) # non in-place install
model_record = store.get_model(key)
assert (mm2_app_config.models_path / model_record.path).exists()
assert not embedding_file.exists()
# ensure file handles are released on Windows
gc.collect()
mm2_installer.delete(key)
# after deletion, installed copy should not exist
assert not (mm2_app_config.models_path / model_record.path).exists()
with pytest.raises(UnknownModelException):
store.get_model(key)
def test_delete_register(
mm2_installer: ModelInstallServiceBase, embedding_file: Path, mm2_app_config: InvokeAIAppConfig
) -> None:
store = mm2_installer.record_store
key = mm2_installer.register_path(embedding_file) # in-place install
model_record = store.get_model(key)
assert Path(model_record.path).exists()
assert embedding_file.exists()
mm2_installer.delete(key)
assert Path(model_record.path).exists()
with pytest.raises(UnknownModelException):
store.get_model(key)
@pytest.mark.timeout(timeout=10, method="thread")
def test_simple_download(mm2_installer: ModelInstallServiceBase, mm2_app_config: InvokeAIAppConfig) -> None:
source = URLModelSource(url=Url("https://www.test.foo/download/test_embedding.safetensors"))
bus: TestEventService = mm2_installer.event_bus
store = mm2_installer.record_store
assert store is not None
assert bus is not None
assert hasattr(bus, "events") # the dummy event service has this
job = mm2_installer.import_model(source)
assert job.source == source
job_list = mm2_installer.wait_for_installs(timeout=10)
assert len(job_list) == 1
assert job.complete
assert job.config_out
key = job.config_out.key
model_record = store.get_model(key)
assert (mm2_app_config.models_path / model_record.path).exists()
assert len(bus.events) == 5
assert isinstance(bus.events[0], ModelInstallDownloadStartedEvent) # download starts
assert isinstance(bus.events[1], ModelInstallDownloadProgressEvent) # download progresses
assert isinstance(bus.events[2], ModelInstallDownloadsCompleteEvent) # download completed
assert isinstance(bus.events[3], ModelInstallStartedEvent) # install started
assert isinstance(bus.events[4], ModelInstallCompleteEvent) # install completed
def test_import_waits_for_startup_restore(
mm2_app_config: InvokeAIAppConfig,
mm2_record_store,
mm2_download_queue,
mm2_session,
embedding_file: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
installer = ModelInstallService(
app_config=mm2_app_config,
record_store=mm2_record_store,
download_queue=mm2_download_queue,
event_bus=TestEventService(),
session=mm2_session,
)
restore_started = threading.Event()
release_restore = threading.Event()
imported = threading.Event()
def _blocked_restore() -> None:
restore_started.set()
assert release_restore.wait(timeout=5)
monkeypatch.setattr(installer, "_restore_incomplete_installs", _blocked_restore)
try:
installer.start()
assert restore_started.wait(timeout=5)
import_thread = threading.Thread(
target=lambda: (
installer.import_model(LocalModelSource(path=embedding_file)),
imported.set(),
)
)
import_thread.start()
time.sleep(0.1)
assert not imported.is_set()
release_restore.set()
import_thread.join(timeout=5)
assert imported.is_set()
installer.wait_for_installs(timeout=5)
finally:
release_restore.set()
installer.stop()
def test_huggingface_blob_url_uses_resolve_download_url(mm2_installer: ModelInstallServiceBase) -> None:
source = URLModelSource(
url=Url("https://huggingface.co/h94/IP-Adapter/blob/main/sdxl_models/ip-adapter.safetensors")
)
assert isinstance(mm2_installer, ModelInstallService)
files, metadata = mm2_installer._remote_files_from_source(source)
assert metadata is None
assert len(files) == 1
assert str(files[0].url) == "https://huggingface.co/h94/IP-Adapter/resolve/main/sdxl_models/ip-adapter.safetensors"
@pytest.mark.timeout(timeout=10, method="thread")
def test_huggingface_install(mm2_installer: ModelInstallServiceBase, mm2_app_config: InvokeAIAppConfig) -> None:
source = URLModelSource(url=Url("https://huggingface.co/stabilityai/sdxl-turbo"))
bus: TestEventService = mm2_installer.event_bus
store = mm2_installer.record_store
assert isinstance(bus, EventServiceBase)
assert store is not None
job = mm2_installer.import_model(source)
job_list = mm2_installer.wait_for_installs(timeout=10)
assert len(job_list) == 1
assert job.complete
assert job.config_out
key = job.config_out.key
model_record = store.get_model(key)
assert (mm2_app_config.models_path / model_record.path).exists()
assert model_record.type == ModelType.Main
assert model_record.format == ModelFormat.Diffusers
assert any(isinstance(x, ModelInstallStartedEvent) for x in bus.events)
assert any(isinstance(x, ModelInstallDownloadProgressEvent) for x in bus.events)
assert any(isinstance(x, ModelInstallCompleteEvent) for x in bus.events)
assert len(bus.events) >= 3
@pytest.mark.timeout(timeout=10, method="thread")
def test_huggingface_repo_id(mm2_installer: ModelInstallServiceBase, mm2_app_config: InvokeAIAppConfig) -> None:
source = HFModelSource(repo_id="stabilityai/sdxl-turbo", variant=ModelRepoVariant.Default)
bus = mm2_installer.event_bus
store = mm2_installer.record_store
assert isinstance(bus, EventServiceBase)
assert store is not None
job = mm2_installer.import_model(source)
job_list = mm2_installer.wait_for_installs(timeout=10)
assert len(job_list) == 1
assert job.complete
assert job.config_out
key = job.config_out.key
model_record = store.get_model(key)
assert (mm2_app_config.models_path / model_record.path).exists()
assert model_record.type == ModelType.Main
assert model_record.format == ModelFormat.Diffusers
assert hasattr(bus, "events") # the dummyeventservice has this
assert len(bus.events) >= 3
event_types = [type(x) for x in bus.events]
assert all(
x in event_types
for x in [
ModelInstallDownloadProgressEvent,
ModelInstallDownloadsCompleteEvent,
ModelInstallStartedEvent,
ModelInstallCompleteEvent,
]
)
completed_events = [x for x in bus.events if isinstance(x, ModelInstallCompleteEvent)]
downloading_events = [x for x in bus.events if isinstance(x, ModelInstallDownloadProgressEvent)]
assert completed_events[0].total_bytes == downloading_events[-1].bytes
assert job.total_bytes == completed_events[0].total_bytes
print(downloading_events[-1])
print(job.download_parts)
assert job.total_bytes == sum(x["total_bytes"] for x in downloading_events[-1].parts)
def test_restore_paused_hf_install_preserves_access_token(
mm2_installer: ModelInstallServiceBase,
mm2_app_config: InvokeAIAppConfig,
mm2_download_queue,
mm2_session,
monkeypatch: pytest.MonkeyPatch,
) -> None:
assert isinstance(mm2_installer, ModelInstallService)
access_token = "hf_test_access_token"
tmpdir = mm2_app_config.models_path / f"tmpinstall_resume_token_{uuid.uuid4().hex}"
tmpdir.mkdir(parents=True, exist_ok=True)
try:
paused_job = ModelInstallJob(
id=99999,
source=HFModelSource(
repo_id="stabilityai/sdxl-turbo",
variant=ModelRepoVariant.Default,
access_token=access_token,
),
config_in=ModelRecordChanges(),
local_path=tmpdir,
)
paused_job._install_tmpdir = tmpdir
paused_job.status = InstallStatus.PAUSED
mm2_installer._write_install_marker(paused_job, status=InstallStatus.PAUSED)
marker = mm2_installer._read_install_marker(tmpdir)
assert marker is not None
assert marker["access_token"] == access_token
restored_installer = ModelInstallService(
app_config=mm2_app_config,
record_store=mm2_installer.record_store,
download_queue=mm2_download_queue,
session=mm2_session,
)
restored_installer._restore_incomplete_installs()
restored_jobs = restored_installer.list_jobs()
assert len(restored_jobs) == 1
restored_job = restored_jobs[0]
assert restored_job.paused
assert isinstance(restored_job.source, HFModelSource)
assert restored_job.source.access_token == access_token
captured: dict[str, str | None] = {}
def _capture_resume(job: ModelInstallJob) -> None:
assert isinstance(job.source, HFModelSource)
captured["access_token"] = job.source.access_token
monkeypatch.setattr(restored_installer, "_resume_remote_download", _capture_resume)
restored_installer.resume_job(restored_job)
assert captured["access_token"] == access_token
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
def test_404_download(mm2_installer: ModelInstallServiceBase, mm2_app_config: InvokeAIAppConfig) -> None:
source = URLModelSource(url=Url("https://test.com/missing_model.safetensors"))
job = mm2_installer.import_model(source)
mm2_installer.wait_for_installs(timeout=10)
assert job.status == InstallStatus.ERROR
assert job.errored
assert job.error_type == "HTTPError"
assert job.error
assert "NOT FOUND" in job.error
assert job.error_traceback is not None
assert job.error_traceback.startswith("Traceback")
bus = mm2_installer.event_bus
assert bus is not None
assert hasattr(bus, "events") # the dummyeventservice has this
event_types = [type(x) for x in bus.events]
assert ModelInstallErrorEvent in event_types
def test_other_error_during_install(
monkeypatch: pytest.MonkeyPatch, mm2_installer: ModelInstallServiceBase, mm2_app_config: InvokeAIAppConfig
) -> None:
def raise_runtime_error(*args, **kwargs):
raise RuntimeError("Test error")
monkeypatch.setattr(
"invokeai.app.services.model_install.model_install_default.ModelInstallService._register_or_install",
raise_runtime_error,
)
source = LocalModelSource(path=Path("tests/data/embedding/test_embedding.safetensors"))
job = mm2_installer.import_model(source)
mm2_installer.wait_for_installs(timeout=10)
assert job.status == InstallStatus.ERROR
assert job.errored
assert job.error_type == "RuntimeError"
assert job.error == "Test error"
@pytest.mark.parametrize(
"model_params",
[
# SDXL, Lora
{
"repo_id": "InvokeAI-test/textual_inversion_tests::learned_embeds-steps-1000.safetensors",
"name": "test_lora",
"type": "embedding",
},
# SDXL, Lora - incorrect type
{
"repo_id": "InvokeAI-test/textual_inversion_tests::learned_embeds-steps-1000.safetensors",
"name": "test_lora",
"type": "lora",
},
],
)
@pytest.mark.timeout(timeout=10, method="thread")
def test_heuristic_import_with_type(mm2_installer: ModelInstallServiceBase, model_params: Dict[str, str]):
"""Test whether or not type is respected on configs when passed to heuristic import."""
assert "name" in model_params and "type" in model_params
config1: Dict[str, Any] = {
"name": f"{model_params['name']}_1",
"type": model_params["type"],
"hash": "placeholder1",
}
config2: Dict[str, Any] = {
"name": f"{model_params['name']}_2",
"type": ModelType(model_params["type"]),
"hash": "placeholder2",
}
assert "repo_id" in model_params
install_job1 = mm2_installer.heuristic_import(source=model_params["repo_id"], config=config1)
mm2_installer.wait_for_job(install_job1, timeout=10)
if model_params["type"] != "embedding":
assert install_job1.errored
assert install_job1.error_type == "InvalidModelConfigException"
return
assert install_job1.complete
assert install_job1.config_out if model_params["type"] == "embedding" else not install_job1.config_out
install_job2 = mm2_installer.heuristic_import(source=model_params["repo_id"], config=config2)
mm2_installer.wait_for_job(install_job2, timeout=10)
assert install_job2.complete
assert install_job2.config_out if model_params["type"] == "embedding" else not install_job2.config_out
@@ -0,0 +1,115 @@
from pathlib import Path
import pytest
import torch
from diffusers import AutoencoderTiny
from invokeai.app.invocations.model import ModelIdentifierField
from invokeai.app.services.invocation_services import InvocationServices
from invokeai.app.services.model_manager import ModelManagerServiceBase
from invokeai.app.services.shared.invocation_context import (
InvocationContext,
InvocationContextData,
build_invocation_context,
)
from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig, ExternalModelCapabilities
from invokeai.backend.model_manager.load.load_base import LoadedModelWithoutConfig
from tests.backend.model_manager.model_manager_fixtures import * # noqa F403
@pytest.fixture()
def mock_context(
mock_services: InvocationServices,
mm2_model_manager: ModelManagerServiceBase,
) -> InvocationContext:
mock_services.model_manager = mm2_model_manager
return build_invocation_context(
services=mock_services,
data=InvocationContextData(queue_item=None, invocation=None, source_invocation_id=None), # type: ignore
is_canceled=None, # type: ignore
)
def test_download_and_cache(mock_context: InvocationContext, mm2_root_dir: Path) -> None:
downloaded_path = mock_context.models.download_and_cache_model(
"https://www.test.foo/download/test_embedding.safetensors"
)
assert downloaded_path.is_file()
assert downloaded_path.exists()
assert downloaded_path.name == "test_embedding.safetensors"
assert downloaded_path.parent.parent == mm2_root_dir / "models/.download_cache"
downloaded_path_2 = mock_context.models.download_and_cache_model(
"https://www.test.foo/download/test_embedding.safetensors"
)
assert downloaded_path == downloaded_path_2
def test_load_from_path(mock_context: InvocationContext, embedding_file: Path) -> None:
downloaded_path = mock_context.models.download_and_cache_model(
"https://www.test.foo/download/test_embedding.safetensors"
)
loaded_model_1 = mock_context.models.load_local_model(downloaded_path)
assert isinstance(loaded_model_1, LoadedModelWithoutConfig)
loaded_model_2 = mock_context.models.load_local_model(downloaded_path)
assert isinstance(loaded_model_2, LoadedModelWithoutConfig)
assert loaded_model_1.model is loaded_model_2.model
loaded_model_3 = mock_context.models.load_local_model(embedding_file)
assert isinstance(loaded_model_3, LoadedModelWithoutConfig)
assert loaded_model_1.model is not loaded_model_3.model
assert isinstance(loaded_model_1.model, dict)
assert isinstance(loaded_model_3.model, dict)
assert torch.equal(loaded_model_1.model["emb_params"], loaded_model_3.model["emb_params"])
@pytest.mark.skip(reason="This requires a test model to load")
def test_load_from_dir(mock_context: InvocationContext, vae_directory: Path) -> None:
loaded_model = mock_context.models.load_local_model(vae_directory)
assert isinstance(loaded_model, LoadedModelWithoutConfig)
assert isinstance(loaded_model.model, AutoencoderTiny)
def test_download_and_load(mock_context: InvocationContext) -> None:
loaded_model_1 = mock_context.models.load_remote_model("https://www.test.foo/download/test_embedding.safetensors")
assert isinstance(loaded_model_1, LoadedModelWithoutConfig)
loaded_model_2 = mock_context.models.load_remote_model("https://www.test.foo/download/test_embedding.safetensors")
assert isinstance(loaded_model_2, LoadedModelWithoutConfig)
assert loaded_model_1.model is loaded_model_2.model # should be cached copy
def test_external_model_load_raises(
mock_context: InvocationContext, mm2_model_manager: ModelManagerServiceBase
) -> None:
config = ExternalApiModelConfig(
key="external_test",
name="External Test",
provider_id="openai",
provider_model_id="gpt-image-1",
capabilities=ExternalModelCapabilities(modes=["txt2img"]),
)
mm2_model_manager.store.add_model(config)
model_field = ModelIdentifierField.from_config(config)
with pytest.raises(ValueError, match="External API models"):
mock_context.models.load(model_field)
with pytest.raises(ValueError, match="External API models"):
mock_context.models.load_by_attrs(name=config.name, base=config.base, type=config.type)
def test_download_diffusers(mock_context: InvocationContext) -> None:
model_path = mock_context.models.download_and_cache_model("stabilityai/sdxl-turbo")
assert (model_path / "model_index.json").exists()
assert (model_path / "vae").is_dir()
def test_download_diffusers_subfolder(mock_context: InvocationContext) -> None:
model_path = mock_context.models.download_and_cache_model("stabilityai/sdxl-turbo::vae")
assert model_path.is_dir()
assert (model_path / "diffusion_pytorch_model.fp16.safetensors").exists() or (
model_path / "diffusion_pytorch_model.safetensors"
).exists()
@@ -0,0 +1,469 @@
"""
Test the refactored model config classes.
"""
from hashlib import sha256
from typing import Any, Optional
import pytest
from pydantic import ValidationError
from invokeai.app.services.config import InvokeAIAppConfig
from invokeai.app.services.model_records import (
DuplicateModelException,
ModelRecordOrderBy,
ModelRecordServiceBase,
ModelRecordServiceSQL,
UnknownModelException,
)
from invokeai.app.services.model_records.model_records_base import ModelRecordChanges
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
from invokeai.backend.model_manager.configs.controlnet import ControlAdapterDefaultSettings
from invokeai.backend.model_manager.configs.lora import LoRA_LyCORIS_SDXL_Config
from invokeai.backend.model_manager.configs.main import (
Main_Diffusers_SD1_Config,
Main_Diffusers_SD2_Config,
Main_Diffusers_SDXL_Config,
MainModelDefaultSettings,
)
from invokeai.backend.model_manager.configs.qwen3_encoder import Qwen3Encoder_Qwen3Encoder_Config
from invokeai.backend.model_manager.configs.text_llm import TextLLM_Diffusers_Config
from invokeai.backend.model_manager.configs.textual_inversion import TI_File_SD1_Config
from invokeai.backend.model_manager.configs.vae import VAE_Diffusers_SD1_Config
from invokeai.backend.model_manager.taxonomy import (
BaseModelType,
ModelFormat,
ModelRepoVariant,
ModelSourceType,
ModelType,
ModelVariantType,
Qwen3VariantType,
SchedulerPredictionType,
)
from invokeai.backend.util.logging import InvokeAILogger
from tests.fixtures.sqlite_database import create_mock_sqlite_database
@pytest.fixture
def store(
datadir: Any,
) -> ModelRecordServiceSQL:
config = InvokeAIAppConfig()
config._root = datadir
logger = InvokeAILogger.get_logger(config=config)
db = create_mock_sqlite_database(config, logger)
return ModelRecordServiceSQL(db, logger)
def example_ti_config(key: Optional[str] = None) -> TI_File_SD1_Config:
config = TI_File_SD1_Config(
source="test/source/",
source_type=ModelSourceType.Path,
path="/tmp/pokemon.bin",
file_size=1024,
name="old name",
base=BaseModelType.StableDiffusion1,
type=ModelType.TextualInversion,
format=ModelFormat.EmbeddingFile,
hash="ABC123",
)
if key is not None:
config.key = key
return config
def test_type(store: ModelRecordServiceBase):
config = example_ti_config("key1")
store.add_model(config)
config1 = store.get_model("key1")
assert isinstance(config1, TI_File_SD1_Config)
def test_raises_on_violating_uniqueness(store: ModelRecordServiceBase):
# Models have a uniqueness constraint by their name, base and type
config1 = example_ti_config("key1")
config2 = config1.model_copy(deep=True)
config2.key = "key2"
store.add_model(config1)
with pytest.raises(DuplicateModelException):
store.add_model(config1)
with pytest.raises(DuplicateModelException):
store.add_model(config2)
def test_model_records_updates_model(store: ModelRecordServiceBase):
config = example_ti_config("key1")
store.add_model(config)
config = store.get_model("key1")
assert config.name == "old name"
new_name = "new name"
changes = ModelRecordChanges(name=new_name)
store.update_model(config.key, changes)
new_config = store.get_model("key1")
assert new_config.name == new_name
def test_model_records_updates_model_class(store: ModelRecordServiceBase):
config = example_ti_config("key1")
store.add_model(config)
changes = ModelRecordChanges(
type=ModelType.LoRA,
format=ModelFormat.LyCORIS,
base=BaseModelType.StableDiffusionXL,
)
new_config = store.update_model(config.key, changes, allow_class_change=True)
assert isinstance(new_config, LoRA_LyCORIS_SDXL_Config)
def test_update_changing_type_drops_stale_format_and_variant(store: ModelRecordServiceBase):
"""When the type changes, format/variant from the old class must not block validation of the new class.
Regression test for https://github.com/invoke-ai/InvokeAI/issues/9090: switching a misidentified
Qwen3 encoder to TextLLM previously failed because the old `format=qwen3_encoder` and `variant`
fields were carried over and no discriminator under `type=text_llm` matched.
"""
config = Qwen3Encoder_Qwen3Encoder_Config(
source="test/source/",
source_type=ModelSourceType.Path,
path="/tmp/Qwen2.5-1.5B-Instruct",
file_size=1024,
name="Qwen2.5-1.5B-Instruct",
hash="ABC123",
variant=Qwen3VariantType.Qwen3_4B,
)
config.key = "key1"
store.add_model(config)
changes = ModelRecordChanges(type=ModelType.TextLLM)
new_config = store.update_model(config.key, changes, allow_class_change=True)
assert isinstance(new_config, TextLLM_Diffusers_Config)
def test_model_records_rejects_invalid_attr_changes(store: ModelRecordServiceBase):
config = example_ti_config("key1")
store.add_model(config)
config = store.get_model("key1")
# upcast_attention is an invalid field for TIs
changes = ModelRecordChanges(upcast_attention=True)
with pytest.raises(ValidationError):
store.update_model(config.key, changes)
def test_model_records_rejects_invalid_attr_changes_that_change_class(store: ModelRecordServiceBase):
config = example_ti_config("key1")
store.add_model(config)
config = store.get_model("key1")
# upcast_attention is an invalid field for TIs
changes = ModelRecordChanges(upcast_attention=True)
with pytest.raises(ValidationError):
store.update_model(config.key, changes)
def test_unknown_key(store: ModelRecordServiceBase):
config = example_ti_config("key1")
store.add_model(config)
with pytest.raises(UnknownModelException):
store.update_model("unknown_key", ModelRecordChanges())
def test_delete(store: ModelRecordServiceBase):
config = example_ti_config("key1")
store.add_model(config)
config = store.get_model("key1")
store.del_model("key1")
with pytest.raises(UnknownModelException):
config = store.get_model("key1")
def test_exists(store: ModelRecordServiceBase):
config = example_ti_config("key1")
store.add_model(config)
assert store.exists("key1")
assert not store.exists("key2")
def test_filter(store: ModelRecordServiceBase):
config1 = Main_Diffusers_SD1_Config(
key="config1",
path="/tmp/config1",
name="config1",
base=BaseModelType.StableDiffusion1,
type=ModelType.Main,
hash="CONFIG1HASH",
file_size=1001,
source="test/source",
source_type=ModelSourceType.Path,
variant=ModelVariantType.Normal,
prediction_type=SchedulerPredictionType.Epsilon,
repo_variant=ModelRepoVariant.Default,
)
config2 = Main_Diffusers_SD1_Config(
key="config2",
path="/tmp/config2",
name="config2",
base=BaseModelType.StableDiffusion1,
type=ModelType.Main,
hash="CONFIG2HASH",
file_size=1002,
source="test/source",
source_type=ModelSourceType.Path,
variant=ModelVariantType.Normal,
prediction_type=SchedulerPredictionType.Epsilon,
repo_variant=ModelRepoVariant.Default,
)
config3 = VAE_Diffusers_SD1_Config(
key="config3",
path="/tmp/config3",
name="config3",
base=BaseModelType.StableDiffusion1,
type=ModelType.VAE,
hash="CONFIG3HASH",
file_size=1003,
source="test/source",
source_type=ModelSourceType.Path,
repo_variant=ModelRepoVariant.Default,
)
for c in config1, config2, config3:
store.add_model(c)
matches = store.search_by_attr(model_type=ModelType.Main)
assert len(matches) == 2
assert matches[0].name in {"config1", "config2"}
matches = store.search_by_attr(model_type=ModelType.VAE)
assert len(matches) == 1
assert matches[0].name == "config3"
assert matches[0].key == "config3"
assert isinstance(matches[0].type, ModelType) # This tests that we get proper enums back
matches = store.search_by_hash("CONFIG1HASH")
assert len(matches) == 1
assert matches[0].hash == "CONFIG1HASH"
matches = store.all_models()
assert len(matches) == 3
def test_unique_by_path(store: ModelRecordServiceBase):
config1 = Main_Diffusers_SD1_Config(
path="/tmp/config1",
base=BaseModelType.StableDiffusion1,
type=ModelType.Main,
name="nonuniquename",
hash="CONFIG1HASH",
file_size=1004,
source="test/source/",
source_type=ModelSourceType.Path,
variant=ModelVariantType.Normal,
prediction_type=SchedulerPredictionType.Epsilon,
repo_variant=ModelRepoVariant.Default,
)
config2 = Main_Diffusers_SD2_Config(
path="/tmp/config2",
base=BaseModelType.StableDiffusion2,
type=ModelType.Main,
name="nonuniquename",
hash="CONFIG1HASH",
file_size=1005,
source="test/source/",
source_type=ModelSourceType.Path,
variant=ModelVariantType.Normal,
prediction_type=SchedulerPredictionType.Epsilon,
repo_variant=ModelRepoVariant.Default,
)
config3 = VAE_Diffusers_SD1_Config(
path="/tmp/config3",
base=BaseModelType.StableDiffusion1,
type=ModelType.VAE,
name="nonuniquename",
hash="CONFIG1HASH",
file_size=1006,
source="test/source/",
source_type=ModelSourceType.Path,
repo_variant=ModelRepoVariant.Default,
)
config4 = Main_Diffusers_SD1_Config(
path="/tmp/config1",
base=BaseModelType.StableDiffusion1,
type=ModelType.Main,
name="nonuniquename",
hash="CONFIG1HASH",
file_size=1007,
source="test/source/",
source_type=ModelSourceType.Path,
variant=ModelVariantType.Normal,
prediction_type=SchedulerPredictionType.Epsilon,
repo_variant=ModelRepoVariant.Default,
)
# config1, config2 and config3 are compatible because they have unique paths
# of name, type and base
for c in config1, config2, config3:
c.key = sha256(c.path.encode("utf-8")).hexdigest()
store.add_model(c)
# config4 clashes with config1 (same path) and should raise an integrity error
with pytest.raises(DuplicateModelException):
config4.key = sha256(config4.path.encode("utf-8")).hexdigest()
store.add_model(config4)
def test_filter_2(store: ModelRecordServiceBase):
config1 = Main_Diffusers_SD1_Config(
path="/tmp/config1",
name="config1",
base=BaseModelType.StableDiffusion1,
type=ModelType.Main,
hash="CONFIG1HASH",
file_size=1008,
source="test/source/",
source_type=ModelSourceType.Path,
variant=ModelVariantType.Normal,
prediction_type=SchedulerPredictionType.Epsilon,
repo_variant=ModelRepoVariant.Default,
)
config2 = Main_Diffusers_SD1_Config(
path="/tmp/config2",
name="config2",
base=BaseModelType.StableDiffusion1,
type=ModelType.Main,
hash="CONFIG2HASH",
file_size=1009,
source="test/source/",
source_type=ModelSourceType.Path,
variant=ModelVariantType.Normal,
prediction_type=SchedulerPredictionType.Epsilon,
repo_variant=ModelRepoVariant.Default,
)
config3 = Main_Diffusers_SD2_Config(
path="/tmp/config3",
name="dup_name1",
base=BaseModelType.StableDiffusion2,
type=ModelType.Main,
hash="CONFIG3HASH",
file_size=1010,
source="test/source/",
source_type=ModelSourceType.Path,
variant=ModelVariantType.Normal,
prediction_type=SchedulerPredictionType.Epsilon,
repo_variant=ModelRepoVariant.Default,
)
config4 = Main_Diffusers_SDXL_Config(
path="/tmp/config4",
name="dup_name1",
base=BaseModelType.StableDiffusionXL,
type=ModelType.Main,
hash="CONFIG3HASH",
file_size=1011,
source="test/source/",
source_type=ModelSourceType.Path,
variant=ModelVariantType.Normal,
prediction_type=SchedulerPredictionType.Epsilon,
repo_variant=ModelRepoVariant.Default,
)
config5 = VAE_Diffusers_SD1_Config(
path="/tmp/config5",
name="dup_name1",
base=BaseModelType.StableDiffusion1,
type=ModelType.VAE,
hash="CONFIG3HASH",
file_size=1012,
source="test/source/",
source_type=ModelSourceType.Path,
repo_variant=ModelRepoVariant.Default,
)
for c in config1, config2, config3, config4, config5:
store.add_model(c)
matches = store.search_by_attr(
model_type=ModelType.Main,
model_name="dup_name1",
)
assert len(matches) == 2
matches = store.search_by_attr(
base_model=BaseModelType.StableDiffusion1,
model_type=ModelType.Main,
)
assert len(matches) == 2
matches = store.search_by_attr(
base_model=BaseModelType.StableDiffusion1,
model_type=ModelType.VAE,
model_name="dup_name1",
)
assert len(matches) == 1
def test_search_by_attr_sorting(store: ModelRecordServiceSQL):
config1 = Main_Diffusers_SD1_Config(
path="/tmp/config1",
name="alpha",
base=BaseModelType.StableDiffusion1,
type=ModelType.Main,
hash="CONFIG1HASH",
file_size=1000,
source="test/source/",
source_type=ModelSourceType.Path,
variant=ModelVariantType.Normal,
prediction_type=SchedulerPredictionType.Epsilon,
repo_variant=ModelRepoVariant.Default,
)
config2 = Main_Diffusers_SD2_Config(
path="/tmp/config2",
name="beta",
base=BaseModelType.StableDiffusion2,
type=ModelType.Main,
hash="CONFIG2HASH",
file_size=2000,
source="test/source/",
source_type=ModelSourceType.Path,
variant=ModelVariantType.Normal,
prediction_type=SchedulerPredictionType.Epsilon,
repo_variant=ModelRepoVariant.Default,
)
config3 = VAE_Diffusers_SD1_Config(
path="/tmp/config3",
name="gamma",
base=BaseModelType.StableDiffusion1,
type=ModelType.VAE,
hash="CONFIG3HASH",
file_size=500,
source="test/source/",
source_type=ModelSourceType.Path,
repo_variant=ModelRepoVariant.Default,
)
for c in config1, config2, config3:
store.add_model(c)
# Test sorting by Name Ascending
matches = store.search_by_attr(order_by=ModelRecordOrderBy.Name, direction=SQLiteDirection.Ascending)
assert len(matches) == 3
assert matches[0].name == "alpha"
assert matches[1].name == "beta"
assert matches[2].name == "gamma"
# Test sorting by Name Descending
matches = store.search_by_attr(order_by=ModelRecordOrderBy.Name, direction=SQLiteDirection.Descending)
assert matches[0].name == "gamma"
assert matches[1].name == "beta"
assert matches[2].name == "alpha"
# Test sorting by Size Ascending
matches = store.search_by_attr(order_by=ModelRecordOrderBy.Size, direction=SQLiteDirection.Ascending)
assert matches[0].name == "gamma" # 500
assert matches[1].name == "alpha" # 1000
assert matches[2].name == "beta" # 2000
# Test sorting by Size Descending
matches = store.search_by_attr(order_by=ModelRecordOrderBy.Size, direction=SQLiteDirection.Descending)
assert matches[0].name == "beta" # 2000
assert matches[1].name == "alpha" # 1000
assert matches[2].name == "gamma" # 500
def test_model_record_changes():
# This test guards against some unexpected behaviours from pydantic's union evaluation. See #6035
changes = ModelRecordChanges.model_validate({"default_settings": {"preprocessor": "value"}})
assert isinstance(changes.default_settings, ControlAdapterDefaultSettings)
changes = ModelRecordChanges.model_validate({"default_settings": {"vae": "value"}})
assert isinstance(changes.default_settings, MainModelDefaultSettings)
@@ -0,0 +1,106 @@
"""Tests for session queue clear() user_id scoping."""
import uuid
import pytest
from invokeai.app.services.invoker import Invoker
from invokeai.app.services.session_queue.session_queue_sqlite import SqliteSessionQueue
@pytest.fixture
def session_queue(mock_invoker: Invoker) -> SqliteSessionQueue:
"""Create a SqliteSessionQueue backed by the mock invoker's in-memory database."""
db = mock_invoker.services.board_records._db
queue = SqliteSessionQueue(db=db)
queue.start(mock_invoker)
return queue
def _insert_queue_item(session_queue: SqliteSessionQueue, queue_id: str, user_id: str) -> None:
"""Directly insert a minimal queue item for the given user."""
session_id = str(uuid.uuid4())
batch_id = str(uuid.uuid4())
with session_queue._db.transaction() as cursor:
cursor.execute(
"""--sql
INSERT INTO session_queue (queue_id, session, session_id, batch_id, field_values, priority, workflow, origin, destination, retried_from_item_id, user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(queue_id, "{}", session_id, batch_id, None, 0, None, None, None, None, user_id),
)
def _count_items(session_queue: SqliteSessionQueue, queue_id: str, user_id: str | None = None) -> int:
"""Count items in the queue, optionally filtered by user_id."""
with session_queue._db.transaction() as cursor:
if user_id is not None:
cursor.execute(
"SELECT COUNT(*) FROM session_queue WHERE queue_id = ? AND user_id = ?",
(queue_id, user_id),
)
else:
cursor.execute(
"SELECT COUNT(*) FROM session_queue WHERE queue_id = ?",
(queue_id,),
)
return cursor.fetchone()[0]
def test_clear_with_user_id_only_deletes_own_items(session_queue: SqliteSessionQueue) -> None:
"""Non-admin clear (user_id provided) should only remove that user's items."""
queue_id = "default"
user_a = "user_a"
user_b = "user_b"
_insert_queue_item(session_queue, queue_id, user_a)
_insert_queue_item(session_queue, queue_id, user_a)
_insert_queue_item(session_queue, queue_id, user_b)
result = session_queue.clear(queue_id, user_id=user_a)
assert result.deleted == 2
assert _count_items(session_queue, queue_id, user_a) == 0
assert _count_items(session_queue, queue_id, user_b) == 1
def test_clear_without_user_id_deletes_all_items(session_queue: SqliteSessionQueue) -> None:
"""Admin clear (no user_id) should remove all items in the queue."""
queue_id = "default"
_insert_queue_item(session_queue, queue_id, "user_a")
_insert_queue_item(session_queue, queue_id, "user_b")
_insert_queue_item(session_queue, queue_id, "user_c")
result = session_queue.clear(queue_id)
assert result.deleted == 3
assert _count_items(session_queue, queue_id) == 0
def test_clear_with_user_id_does_not_affect_other_queues(session_queue: SqliteSessionQueue) -> None:
"""Clearing one queue should not affect items in another queue."""
queue_a = "queue_a"
queue_b = "queue_b"
user_id = "user_x"
_insert_queue_item(session_queue, queue_a, user_id)
_insert_queue_item(session_queue, queue_b, user_id)
result = session_queue.clear(queue_a, user_id=user_id)
assert result.deleted == 1
assert _count_items(session_queue, queue_a) == 0
assert _count_items(session_queue, queue_b) == 1
def test_clear_returns_zero_when_no_matching_items(session_queue: SqliteSessionQueue) -> None:
"""Clear should return 0 deleted when there are no items for the given user."""
queue_id = "default"
_insert_queue_item(session_queue, queue_id, "user_b")
result = session_queue.clear(queue_id, user_id="user_a")
assert result.deleted == 0
assert _count_items(session_queue, queue_id) == 1
@@ -0,0 +1,293 @@
"""Tests for session queue dequeue() ordering: FIFO and round-robin modes."""
import json
import uuid
from typing import Optional
import pytest
from pydantic_core import to_jsonable_python
from invokeai.app.services.config.config_default import InvokeAIAppConfig
from invokeai.app.services.invoker import Invoker
from invokeai.app.services.session_queue.session_queue_sqlite import (
ROUND_ROBIN_DEQUEUE_QUERY,
SqliteSessionQueue,
)
from invokeai.app.services.shared.graph import Graph, GraphExecutionState
_EMPTY_SESSION_JSON = json.dumps(to_jsonable_python(GraphExecutionState(graph=Graph()).model_dump()))
@pytest.fixture
def session_queue_fifo(mock_invoker: Invoker) -> SqliteSessionQueue:
"""Queue backed by a single-user (FIFO) invoker."""
# Default config has multiuser=False, so FIFO is always used.
db = mock_invoker.services.board_records._db
queue = SqliteSessionQueue(db=db)
queue.start(mock_invoker)
return queue
@pytest.fixture
def session_queue_round_robin(mock_invoker: Invoker) -> SqliteSessionQueue:
"""Queue backed by a multiuser invoker with round_robin mode."""
mock_invoker.services.configuration = InvokeAIAppConfig(
use_memory_db=True,
node_cache_size=0,
multiuser=True,
session_queue_mode="round_robin",
)
db = mock_invoker.services.board_records._db
queue = SqliteSessionQueue(db=db)
queue.start(mock_invoker)
return queue
def _insert_queue_item(
session_queue: SqliteSessionQueue,
queue_id: str,
user_id: str,
priority: int = 0,
) -> int:
"""Directly insert a minimal queue item and return its item_id."""
session_id = str(uuid.uuid4())
batch_id = str(uuid.uuid4())
with session_queue._db.transaction() as cursor:
cursor.execute(
"""--sql
INSERT INTO session_queue (queue_id, session, session_id, batch_id, field_values, priority, workflow, origin, destination, retried_from_item_id, user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(queue_id, _EMPTY_SESSION_JSON, session_id, batch_id, None, priority, None, None, None, None, user_id),
)
return cursor.lastrowid # type: ignore[return-value]
def _dequeue_user_ids(session_queue: SqliteSessionQueue, count: int) -> list[Optional[str]]:
"""Dequeue `count` items and return the list of user_ids in dequeue order."""
result = []
for _ in range(count):
item = session_queue.dequeue()
result.append(item.user_id if item is not None else None)
return result
# ---------------------------------------------------------------------------
# FIFO tests
# ---------------------------------------------------------------------------
def test_fifo_single_user_order(session_queue_fifo: SqliteSessionQueue) -> None:
"""FIFO: items from a single user are dequeued in insertion order."""
queue_id = "default"
_insert_queue_item(session_queue_fifo, queue_id, "user_a")
_insert_queue_item(session_queue_fifo, queue_id, "user_a")
_insert_queue_item(session_queue_fifo, queue_id, "user_a")
user_ids = _dequeue_user_ids(session_queue_fifo, 3)
assert user_ids == ["user_a", "user_a", "user_a"]
def test_fifo_multi_user_preserves_insertion_order(session_queue_fifo: SqliteSessionQueue) -> None:
"""FIFO: jobs from multiple users are dequeued in strict insertion order, not interleaved."""
queue_id = "default"
# Insert A1, A2, B1, C1, C2, A3 FIFO should preserve this exact order.
_insert_queue_item(session_queue_fifo, queue_id, "user_a")
_insert_queue_item(session_queue_fifo, queue_id, "user_a")
_insert_queue_item(session_queue_fifo, queue_id, "user_b")
_insert_queue_item(session_queue_fifo, queue_id, "user_c")
_insert_queue_item(session_queue_fifo, queue_id, "user_c")
_insert_queue_item(session_queue_fifo, queue_id, "user_a")
user_ids = _dequeue_user_ids(session_queue_fifo, 6)
assert user_ids == ["user_a", "user_a", "user_b", "user_c", "user_c", "user_a"]
def test_fifo_priority_respected(session_queue_fifo: SqliteSessionQueue) -> None:
"""FIFO: higher-priority items are dequeued before lower-priority ones."""
queue_id = "default"
_insert_queue_item(session_queue_fifo, queue_id, "user_a", priority=0)
_insert_queue_item(session_queue_fifo, queue_id, "user_a", priority=10)
user_ids = _dequeue_user_ids(session_queue_fifo, 2)
# Both are user_a; second inserted item has higher priority and should come first.
assert user_ids == ["user_a", "user_a"]
def test_fifo_returns_none_when_empty(session_queue_fifo: SqliteSessionQueue) -> None:
"""FIFO: dequeue returns None when the queue is empty."""
assert session_queue_fifo.dequeue() is None
# ---------------------------------------------------------------------------
# Round-robin tests
# ---------------------------------------------------------------------------
def test_round_robin_interleaves_users(session_queue_round_robin: SqliteSessionQueue) -> None:
"""Round-robin: jobs from multiple users are interleaved one per user per round.
Queue insertion order (matching the issue example):
A job 1, A job 2, B job 1, C job 1, C job 2, A job 3
Expected dequeue order:
A job 1, B job 1, C job 1, A job 2, C job 2, A job 3
"""
queue_id = "default"
_insert_queue_item(session_queue_round_robin, queue_id, "user_a")
_insert_queue_item(session_queue_round_robin, queue_id, "user_a")
_insert_queue_item(session_queue_round_robin, queue_id, "user_b")
_insert_queue_item(session_queue_round_robin, queue_id, "user_c")
_insert_queue_item(session_queue_round_robin, queue_id, "user_c")
_insert_queue_item(session_queue_round_robin, queue_id, "user_a")
user_ids = _dequeue_user_ids(session_queue_round_robin, 6)
assert user_ids == ["user_a", "user_b", "user_c", "user_a", "user_c", "user_a"]
def test_round_robin_single_user_behaves_like_fifo(session_queue_round_robin: SqliteSessionQueue) -> None:
"""Round-robin with only one user produces the same order as FIFO."""
queue_id = "default"
_insert_queue_item(session_queue_round_robin, queue_id, "user_a")
_insert_queue_item(session_queue_round_robin, queue_id, "user_a")
_insert_queue_item(session_queue_round_robin, queue_id, "user_a")
user_ids = _dequeue_user_ids(session_queue_round_robin, 3)
assert user_ids == ["user_a", "user_a", "user_a"]
def test_round_robin_handles_user_joining_mid_queue(session_queue_round_robin: SqliteSessionQueue) -> None:
"""Round-robin: a user who joins later is correctly interleaved."""
queue_id = "default"
_insert_queue_item(session_queue_round_robin, queue_id, "user_a")
_insert_queue_item(session_queue_round_robin, queue_id, "user_a")
_insert_queue_item(session_queue_round_robin, queue_id, "user_b")
user_ids = _dequeue_user_ids(session_queue_round_robin, 3)
# Round 1: A (oldest rank-1 item), B (rank-1 item)
# Round 2: A (rank-2 item)
assert user_ids == ["user_a", "user_b", "user_a"]
def test_round_robin_returns_none_when_empty(session_queue_round_robin: SqliteSessionQueue) -> None:
"""Round-robin: dequeue returns None when the queue is empty."""
assert session_queue_round_robin.dequeue() is None
def test_round_robin_priority_within_user_respected(session_queue_round_robin: SqliteSessionQueue) -> None:
"""Round-robin: within a single user's items, higher priority is dequeued first."""
queue_id = "default"
# Insert low-priority item first, then high-priority for same user.
_insert_queue_item(session_queue_round_robin, queue_id, "user_a", priority=0)
_insert_queue_item(session_queue_round_robin, queue_id, "user_a", priority=10)
_insert_queue_item(session_queue_round_robin, queue_id, "user_b", priority=0)
# Round 1: user_a's best item (priority 10), user_b's only item.
# Round 2: user_a's remaining item (priority 0).
items = []
for _ in range(3):
item = session_queue_round_robin.dequeue()
assert item is not None
items.append((item.user_id, item.priority))
assert items[0] == ("user_a", 10)
assert items[1] == ("user_b", 0)
assert items[2] == ("user_a", 0)
def _seed_completed_history(
session_queue: SqliteSessionQueue,
queue_id: str,
user_id: str,
count: int,
) -> None:
"""Insert `count` completed items (with started_at set) for a user, simulating retained history."""
with session_queue._db.transaction() as cursor:
for i in range(count):
session_id = str(uuid.uuid4())
batch_id = str(uuid.uuid4())
cursor.execute(
"""--sql
INSERT INTO session_queue
(queue_id, session, session_id, batch_id, field_values, priority, workflow, origin, destination, retried_from_item_id, user_id, status, started_at, completed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'completed', ?, ?)
""",
(
queue_id,
_EMPTY_SESSION_JSON,
session_id,
batch_id,
None,
0,
None,
None,
None,
None,
user_id,
# Monotonically increasing timestamps so MAX(started_at) is well-defined per user.
f"2026-01-01 {i // 3600 % 24:02d}:{i // 60 % 60:02d}:{i % 60:02d}",
f"2026-01-01 {i // 3600 % 24:02d}:{i // 60 % 60:02d}:{i % 60:02d}",
),
)
def test_round_robin_dequeue_does_not_scan_full_history(session_queue_round_robin: SqliteSessionQueue) -> None:
"""Round-robin dequeue cost must scale with active users, not retained queue history.
Regression guard for the scaling concern: the per-user "last served" lookup must be an
indexed seek (MAX(started_at) WHERE user_id = ?) rather than a GROUP BY / scan over every
historical started row. `max_queue_history` is unbounded by default, so a plan that scans
the full history makes each dequeue O(total history) instead of O(active users).
We seed a large completed history across several users plus a few pending items, then assert
the dequeue query plan never scans the `session_queue` base table and resolves the
last-served lookup via a seek on `idx_session_queue_user_started_at`.
"""
queue_id = "default"
for u in ("user_a", "user_b", "user_c"):
_seed_completed_history(session_queue_round_robin, queue_id, u, count=500)
_insert_queue_item(session_queue_round_robin, queue_id, u)
with session_queue_round_robin._db.transaction() as cursor:
plan_rows = cursor.execute("EXPLAIN QUERY PLAN " + ROUND_ROBIN_DEQUEUE_QUERY).fetchall()
details = [row["detail"] for row in plan_rows]
# No step may scan the session_queue base table — that is the full-history scan we are
# eliminating. (CTE result scans like "SCAN uni" / "SCAN (subquery-N)" are fine; those are
# one row per pending user.)
offending = [d for d in details if d.startswith("SCAN session_queue")]
assert not offending, f"dequeue plan scans full queue history: {offending}\nfull plan: {details}"
# The last-served lookup must use the started_at index as a per-user seek.
assert any("idx_session_queue_user_started_at" in d and "user_id=?" in d for d in details), (
f"last-served lookup is not an indexed seek; plan: {details}"
)
# And the dequeue must still return the least-recently-served user (correctness under history).
# user_a's history ends earliest only if seeded first; all three were seeded equal counts with
# identical timestamps, so item_id ASC tie-breaks to the first-inserted pending item (user_a).
item = session_queue_round_robin.dequeue()
assert item is not None
assert item.user_id == "user_a"
def test_round_robin_ignored_in_single_user_mode(mock_invoker: Invoker) -> None:
"""When multiuser=False, round_robin config is ignored and FIFO is used."""
mock_invoker.services.configuration = InvokeAIAppConfig(
use_memory_db=True,
node_cache_size=0,
multiuser=False,
session_queue_mode="round_robin",
)
db = mock_invoker.services.board_records._db
queue = SqliteSessionQueue(db=db)
queue.start(mock_invoker)
queue_id = "default"
_insert_queue_item(queue, queue_id, "user_a")
_insert_queue_item(queue, queue_id, "user_a")
_insert_queue_item(queue, queue_id, "user_b")
# FIFO order: user_a, user_a, user_b
user_ids = _dequeue_user_ids(queue, 3)
assert user_ids == ["user_a", "user_a", "user_b"]
@@ -0,0 +1,283 @@
"""Regression tests for the cross-user identifier leak in QueueItemStatusChangedEvent.
When user A's queue item changes status while user B's item is currently in_progress,
the embedded SessionQueueStatus inside the event must NOT expose B's item_id,
session_id, or batch_id. The full event ships to user:{A.user_id} and admin rooms,
so unredacted fields would let owner A learn user B's identifiers.
"""
import uuid
from typing import Optional
import pytest
from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
from invokeai.app.services.events.events_common import QueueItemStatusChangedEvent
from invokeai.app.services.invoker import Invoker
from invokeai.app.services.session_queue.session_queue_common import SessionQueueItem
from invokeai.app.services.session_queue.session_queue_sqlite import SqliteSessionQueue
from invokeai.app.services.shared.graph import Graph, GraphExecutionState
from tests.test_nodes import PromptTestInvocation, TestEventService
@pytest.fixture
def session_queue(mock_invoker: Invoker) -> SqliteSessionQueue:
db = mock_invoker.services.board_records._db
queue = SqliteSessionQueue(db=db)
queue.start(mock_invoker)
return queue
def _insert_queue_item(session_queue: SqliteSessionQueue, user_id: str) -> int:
graph = Graph()
graph.add_node(PromptTestInvocation(id="prompt", prompt="test"))
session = GraphExecutionState(graph=graph)
session_json = session.model_dump_json(warnings=False, exclude_none=True)
batch_id = str(uuid.uuid4())
with session_queue._db.transaction() as cursor:
cursor.execute(
"""--sql
INSERT INTO session_queue (
queue_id, session, session_id, batch_id, field_values,
priority, workflow, origin, destination, retried_from_item_id, user_id
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
("default", session_json, session.id, batch_id, None, 0, None, None, None, None, user_id),
)
return cursor.lastrowid # type: ignore[return-value]
def _insert_waiting_workflow_call_parent(
session_queue: SqliteSessionQueue, user_id: str
) -> tuple[int, GraphExecutionState]:
parent_graph = Graph()
parent_graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-a"))
parent_session = GraphExecutionState(graph=parent_graph)
invocation = parent_session.next()
assert isinstance(invocation, CallSavedWorkflowInvocation)
frame = parent_session.build_workflow_call_frame(invocation.id, invocation.workflow_id)
child_session = parent_session.create_child_workflow_execution_state(Graph(), frame)
parent_session.begin_waiting_on_workflow_call(frame)
parent_session.attach_waiting_workflow_call_child_session(child_session)
batch_id = str(uuid.uuid4())
with session_queue._db.transaction() as cursor:
cursor.execute(
"""--sql
INSERT INTO session_queue (
queue_id, session, session_id, batch_id, field_values,
priority, workflow, origin, destination, retried_from_item_id, user_id, status
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
"default",
parent_session.model_dump_json(warnings=False, exclude_none=True),
parent_session.id,
batch_id,
None,
0,
None,
None,
None,
None,
user_id,
"waiting",
),
)
return cursor.lastrowid, child_session # type: ignore[return-value]
def _last_status_event_for_item(event_bus: TestEventService, item_id: int) -> QueueItemStatusChangedEvent:
matches = [e for e in event_bus.events if isinstance(e, QueueItemStatusChangedEvent) and e.item_id == item_id]
assert matches, f"No QueueItemStatusChangedEvent found for item {item_id}"
return matches[-1]
def test_event_redacts_other_users_current_item_identifiers(
session_queue: SqliteSessionQueue, mock_invoker: Invoker
) -> None:
"""When user A's pending item is canceled while user B's item is in_progress, the
embedded queue_status in A's status-changed event must not expose B's identifiers."""
user_a = "user-a"
user_b = "user-b"
a_item_id = _insert_queue_item(session_queue, user_id=user_a)
b_item_id = _insert_queue_item(session_queue, user_id=user_b)
# Make user B's item the in-progress one. We must dequeue B first; FIFO would dequeue A
# because it was inserted first, so reverse the insertion: cancel A's, re-insert as new.
# Simpler: dequeue twice. First dequeue picks A (older); promote B by inserting in
# right order means we need B to be the in_progress item when A's event fires.
# Cancel A first to make it ineligible, then dequeue B.
# Actually we need A to be pending when its status changes — so we must dequeue B first.
# Re-do: insert B BEFORE A by temporarily inserting A second. Recreate cleanly:
session_queue.delete_queue_item(a_item_id)
session_queue.delete_queue_item(b_item_id)
b_item_id = _insert_queue_item(session_queue, user_id=user_b)
a_item_id = _insert_queue_item(session_queue, user_id=user_a)
in_progress = session_queue.dequeue()
assert in_progress is not None and in_progress.item_id == b_item_id
assert in_progress.user_id == user_b
event_bus: TestEventService = mock_invoker.services.events
event_bus.events.clear()
# Now cancel user A's pending item. The emitted event for A must not leak B's
# current-item identifiers via the embedded queue_status.
canceled = session_queue.cancel_queue_item(a_item_id)
assert canceled.user_id == user_a
a_event = _last_status_event_for_item(event_bus, a_item_id)
assert a_event.user_id == user_a
assert a_event.queue_status.item_id is None, "must not leak other user's current item_id"
assert a_event.queue_status.session_id is None, "must not leak other user's current session_id"
assert a_event.queue_status.batch_id is None, "must not leak other user's current batch_id"
# Aggregate counts in the embedded status are global and OK to share.
assert a_event.queue_status.in_progress == 1
assert a_event.queue_status.canceled == 1
def test_event_preserves_owner_current_item_identifiers(
session_queue: SqliteSessionQueue, mock_invoker: Invoker
) -> None:
"""When the current in-progress item belongs to the same user as the changed item, the
embedded queue_status must continue to expose the identifiers (no over-redaction)."""
user_a = "user-a"
a_item_id = _insert_queue_item(session_queue, user_id=user_a)
in_progress = session_queue.dequeue()
assert in_progress is not None and in_progress.item_id == a_item_id
event_bus: TestEventService = mock_invoker.services.events
event_bus.events.clear()
completed = session_queue.complete_queue_item(a_item_id)
assert completed.user_id == user_a
# The event for A's transition fires AFTER the row is marked completed, so by the time
# _set_queue_item_status reads get_current it returns None — there is no in-progress
# item to leak. queue_status fields should therefore be None.
a_event = _last_status_event_for_item(event_bus, a_item_id)
assert a_event.user_id == user_a
assert a_event.queue_status.item_id is None # no in-progress item at all
assert a_event.queue_status.completed == 1
def test_event_redacts_when_current_item_disappears_between_reads(
session_queue: SqliteSessionQueue,
mock_invoker: Invoker,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Regression: _set_queue_item_status reads get_current twice — once via
get_queue_status (which embeds the in-progress item's identifiers into the
SessionQueueStatus) and once again to decide whether to redact those
identifiers. The two reads are not atomic. If user B is the in-progress
item when the first read happens, and B then completes/cancels before the
second read, the redaction guard sees current_item is None and skips
scrubbing — leaving B's item_id, session_id, and batch_id in the event
sent to user A. The fix must make the redaction decision derive from the
same snapshot that supplied the embedded identifiers."""
user_a = "user-a"
user_b = "user-b"
b_item_id = _insert_queue_item(session_queue, user_id=user_b)
a_item_id = _insert_queue_item(session_queue, user_id=user_a)
in_progress = session_queue.dequeue()
assert in_progress is not None and in_progress.item_id == b_item_id
assert in_progress.user_id == user_b
real_get_current = session_queue.get_current
b_snapshot = real_get_current(queue_id="default")
assert b_snapshot is not None and b_snapshot.user_id == user_b
# Simulate the race: the read inside get_queue_status sees B's in-progress
# item; the redaction read returns None as if B finished in between.
call_count = {"n": 0}
def racey_get_current(queue_id: str) -> Optional[SessionQueueItem]:
call_count["n"] += 1
if call_count["n"] == 1:
return b_snapshot
return None
monkeypatch.setattr(session_queue, "get_current", racey_get_current)
event_bus: TestEventService = mock_invoker.services.events
event_bus.events.clear()
canceled = session_queue.cancel_queue_item(a_item_id)
assert canceled.user_id == user_a
# The patched read must have been consulted at least once. The pre-fix
# implementation made two reads (embedding + redaction) and leaked when
# the second returned None; the fixed implementation makes a single read
# whose snapshot drives both embedding and redaction. Either way, the
# invariant below is the one that matters.
assert call_count["n"] >= 1
a_event = _last_status_event_for_item(event_bus, a_item_id)
assert a_event.user_id == user_a
assert a_event.queue_status.item_id is None, (
"race-window leak: other user's item_id survived because the second "
"get_current() returned None and the redaction guard was skipped"
)
assert a_event.queue_status.session_id is None, "race-window leak of session_id"
assert a_event.queue_status.batch_id is None, "race-window leak of batch_id"
def test_event_preserves_identifiers_when_current_item_is_the_changed_item(
session_queue: SqliteSessionQueue, mock_invoker: Invoker
) -> None:
"""The dequeue() transition makes the changed item itself the in-progress current item.
queue_status must expose its identifiers since they belong to the event's owner."""
user_a = "user-a"
a_item_id = _insert_queue_item(session_queue, user_id=user_a)
event_bus: TestEventService = mock_invoker.services.events
event_bus.events.clear()
in_progress = session_queue.dequeue()
assert in_progress is not None and in_progress.item_id == a_item_id
a_event = _last_status_event_for_item(event_bus, a_item_id)
assert a_event.status == "in_progress"
assert a_event.user_id == user_a
# Current item == changed item == owned by user_a → no redaction
assert a_event.queue_status.item_id == a_item_id
assert a_event.queue_status.session_id == in_progress.session_id
assert a_event.queue_status.batch_id == in_progress.batch_id
def test_workflow_call_child_enqueue_event_redacts_other_users_current_item_identifiers(
session_queue: SqliteSessionQueue, mock_invoker: Invoker
) -> None:
"""The child enqueue path emits QueueItemStatusChangedEvent without going through
_set_queue_item_status, so it must apply the same per-owner current-item redaction."""
user_a = "user-a"
user_b = "user-b"
b_item_id = _insert_queue_item(session_queue, user_id=user_b)
parent_item_id, child_session = _insert_waiting_workflow_call_parent(session_queue, user_id=user_a)
in_progress = session_queue.dequeue()
assert in_progress is not None and in_progress.item_id == b_item_id
assert in_progress.user_id == user_b
event_bus: TestEventService = mock_invoker.services.events
event_bus.events.clear()
parent_queue_item = session_queue.get_queue_item(parent_item_id)
child_queue_item = session_queue.enqueue_workflow_call_child(parent_queue_item, child_session)
child_event = _last_status_event_for_item(event_bus, child_queue_item.item_id)
assert child_event.user_id == user_a
assert child_event.queue_status.item_id is None, "must not leak other user's current item_id"
assert child_event.queue_status.session_id is None, "must not leak other user's current session_id"
assert child_event.queue_status.batch_id is None, "must not leak other user's current batch_id"
assert child_event.queue_status.in_progress == 1
assert child_event.queue_status.waiting == 1
@@ -0,0 +1,103 @@
import uuid
import pytest
from invokeai.app.services.events.events_common import QueueItemStatusChangedEvent
from invokeai.app.services.invoker import Invoker
from invokeai.app.services.session_queue.session_queue_sqlite import SqliteSessionQueue
from invokeai.app.services.shared.graph import Graph, GraphExecutionState
from tests.test_nodes import PromptTestInvocation, TestEventService
@pytest.fixture
def session_queue(mock_invoker: Invoker) -> SqliteSessionQueue:
db = mock_invoker.services.board_records._db
queue = SqliteSessionQueue(db=db)
queue.start(mock_invoker)
return queue
def _insert_queue_item(
session_queue: SqliteSessionQueue,
queue_id: str = "default",
destination: str | None = None,
) -> int:
graph = Graph()
graph.add_node(PromptTestInvocation(id="prompt", prompt="test"))
session = GraphExecutionState(graph=graph)
session_json = session.model_dump_json(warnings=False, exclude_none=True)
batch_id = str(uuid.uuid4())
with session_queue._db.transaction() as cursor:
cursor.execute(
"""--sql
INSERT INTO session_queue (
queue_id,
session,
session_id,
batch_id,
field_values,
priority,
workflow,
origin,
destination,
retried_from_item_id,
user_id
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(queue_id, session_json, session.id, batch_id, None, 0, None, None, destination, None, "system"),
)
return cursor.lastrowid
def test_status_sequence_increments_for_queue_item_lifecycle(
session_queue: SqliteSessionQueue, mock_invoker: Invoker
) -> None:
item_id = _insert_queue_item(session_queue)
pending_item = session_queue.get_queue_item(item_id)
assert pending_item.status == "pending"
assert pending_item.status_sequence == 0
in_progress_item = session_queue.dequeue()
assert in_progress_item is not None
assert in_progress_item.item_id == item_id
assert in_progress_item.status == "in_progress"
assert in_progress_item.status_sequence == 1
completed_item = session_queue.complete_queue_item(item_id)
assert completed_item.status == "completed"
assert completed_item.status_sequence == 2
event_bus: TestEventService = mock_invoker.services.events
status_events = [event for event in event_bus.events if isinstance(event, QueueItemStatusChangedEvent)]
assert len(status_events) == 2
assert [event.status for event in status_events] == ["in_progress", "completed"]
assert [event.status_sequence for event in status_events] == [1, 2]
def test_status_sequence_increments_for_bulk_cancel_paths(session_queue: SqliteSessionQueue) -> None:
first_item_id = _insert_queue_item(session_queue)
second_item_id = _insert_queue_item(session_queue)
result = session_queue.cancel_all_except_current("default")
assert result.canceled == 2
assert session_queue.get_queue_item(first_item_id).status == "canceled"
assert session_queue.get_queue_item(first_item_id).status_sequence == 1
assert session_queue.get_queue_item(second_item_id).status == "canceled"
assert session_queue.get_queue_item(second_item_id).status_sequence == 1
def test_status_sequence_continues_after_dequeue_then_cancel(session_queue: SqliteSessionQueue) -> None:
item_id = _insert_queue_item(session_queue)
in_progress_item = session_queue.dequeue()
assert in_progress_item is not None
assert in_progress_item.item_id == item_id
assert in_progress_item.status_sequence == 1
canceled_item = session_queue.cancel_queue_item(item_id)
assert canceled_item.status == "canceled"
assert canceled_item.status_sequence == 2
@@ -0,0 +1,119 @@
"""Regression tests for multiuser queue status / list scoping.
The queue badge in multiuser mode shows "X/Y" where X is the requesting user's own
pending+in_progress jobs and Y is the global total across all users. For this to work,
get_queue_status must report GLOBAL aggregate counts and ADDITIONALLY return the requesting
user's own counts in user_pending/user_in_progress.
Separately, the virtualized queue list fetches ids via get_queue_item_ids and hydrates them
via get_queue_items_by_item_ids (which redacts other users' items). So get_queue_item_ids must
return every user's ids — otherwise a non-admin never sees the (redacted) entries belonging to
other users.
A regression (#9018) scoped both of these to the calling user, which (a) collapsed the badge to
a single own-count and (b) hid other users' redacted entries entirely. These tests guard the
restored behavior.
"""
import uuid
import pytest
from invokeai.app.services.invoker import Invoker
from invokeai.app.services.session_queue.session_queue_sqlite import SqliteSessionQueue
from invokeai.app.services.shared.graph import Graph, GraphExecutionState
from tests.test_nodes import PromptTestInvocation
@pytest.fixture
def session_queue(mock_invoker: Invoker) -> SqliteSessionQueue:
db = mock_invoker.services.board_records._db
queue = SqliteSessionQueue(db=db)
queue.start(mock_invoker)
return queue
def _insert_queue_item(session_queue: SqliteSessionQueue, user_id: str) -> int:
graph = Graph()
graph.add_node(PromptTestInvocation(id="prompt", prompt="test"))
session = GraphExecutionState(graph=graph)
session_json = session.model_dump_json(warnings=False, exclude_none=True)
batch_id = str(uuid.uuid4())
with session_queue._db.transaction() as cursor:
cursor.execute(
"""--sql
INSERT INTO session_queue (
queue_id, session, session_id, batch_id, field_values,
priority, workflow, origin, destination, retried_from_item_id, user_id
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
("default", session_json, session.id, batch_id, None, 0, None, None, None, None, user_id),
)
return cursor.lastrowid # type: ignore[return-value]
def test_status_aggregate_counts_are_global_with_user_subcounts(session_queue: SqliteSessionQueue) -> None:
"""A non-admin caller (user_id set) sees global aggregate counts plus their own subcounts."""
user_a = "user-a"
user_b = "user-b"
_insert_queue_item(session_queue, user_id=user_a)
_insert_queue_item(session_queue, user_id=user_a)
_insert_queue_item(session_queue, user_id=user_b)
status = session_queue.get_queue_status(queue_id="default", user_id=user_a)
# Global counts span every user's pending items.
assert status.pending == 3
assert status.total == 3
# Per-user subcounts reflect only user A's items → badge renders "2/3".
assert status.user_pending == 2
assert status.user_in_progress == 0
def test_status_admin_global_call_omits_user_subcounts(session_queue: SqliteSessionQueue) -> None:
"""An admin/global caller (user_id=None) gets global counts and no per-user subcounts."""
_insert_queue_item(session_queue, user_id="user-a")
_insert_queue_item(session_queue, user_id="user-b")
status = session_queue.get_queue_status(queue_id="default")
assert status.pending == 2
assert status.total == 2
assert status.user_pending is None
assert status.user_in_progress is None
def test_status_current_item_redacted_for_non_owner_but_counts_global(session_queue: SqliteSessionQueue) -> None:
"""When the in-progress item belongs to another user, its identifiers are hidden from a
non-owner, but the aggregate counts (and the user's own subcounts) remain populated."""
user_a = "user-a"
user_b = "user-b"
b_item_id = _insert_queue_item(session_queue, user_id=user_b)
_insert_queue_item(session_queue, user_id=user_a)
in_progress = session_queue.dequeue()
assert in_progress is not None and in_progress.item_id == b_item_id
status = session_queue.get_queue_status(queue_id="default", user_id=user_a)
# B's in-progress item identifiers are hidden from A.
assert status.item_id is None
assert status.session_id is None
assert status.batch_id is None
# But counts are still global, and A's own subcounts are present.
assert status.in_progress == 1 # B's item, counted globally
assert status.user_in_progress == 0 # A owns none in progress
assert status.user_pending == 1 # A's single pending item
def test_get_queue_item_ids_returns_all_users_ids(session_queue: SqliteSessionQueue) -> None:
"""get_queue_item_ids returns ids for every user so the virtualized list can show the
(redacted) entries belonging to other users. Redaction happens at hydration time."""
a_item_id = _insert_queue_item(session_queue, user_id="user-a")
b_item_id = _insert_queue_item(session_queue, user_id="user-b")
result = session_queue.get_queue_item_ids(queue_id="default")
assert set(result.item_ids) == {a_item_id, b_item_id}
assert result.total_count == 2
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,133 @@
"""Tests for workflow-call retry semantics in the session queue."""
from datetime import datetime
import pytest
from invokeai.app.services.events.events_common import QueueItemsRetriedEvent
from invokeai.app.services.invoker import Invoker
from invokeai.app.services.session_queue.session_queue_common import SessionQueueItem
from invokeai.app.services.session_queue.session_queue_sqlite import SqliteSessionQueue
from invokeai.app.services.shared.graph import Graph, GraphExecutionState
from tests.test_nodes import TestEventService
@pytest.fixture
def session_queue(mock_invoker: Invoker) -> SqliteSessionQueue:
db = mock_invoker.services.board_records._db
queue = SqliteSessionQueue(db=db)
queue.start(mock_invoker)
return queue
@pytest.fixture
def event_bus(mock_invoker: Invoker) -> TestEventService:
assert isinstance(mock_invoker.services.events, TestEventService)
return mock_invoker.services.events
def _build_queue_item(
*,
item_id: int,
session: GraphExecutionState,
user_id: str,
status: str,
root_item_id: int | None = None,
retried_from_item_id: int | None = None,
) -> SessionQueueItem:
now = datetime.now()
return SessionQueueItem(
item_id=item_id,
status=status,
priority=0,
batch_id=f"batch-{item_id}",
origin=None,
destination=None,
session_id=session.id,
error_type=None,
error_message=None,
error_traceback=None,
created_at=now,
updated_at=now,
started_at=None,
completed_at=None,
queue_id="default",
user_id=user_id,
user_display_name=None,
user_email=None,
field_values=None,
retried_from_item_id=retried_from_item_id,
workflow_call_id=None,
parent_item_id=None,
parent_session_id=None,
root_item_id=root_item_id,
workflow_call_depth=None,
session=session,
workflow=None,
)
def test_retry_items_by_id_retries_root_once_for_child_chain_item(
session_queue: SqliteSessionQueue, event_bus: TestEventService, monkeypatch: pytest.MonkeyPatch
) -> None:
root_session = GraphExecutionState(graph=Graph())
child_session = GraphExecutionState(graph=Graph())
root_item = _build_queue_item(item_id=10, session=root_session, user_id="user-1", status="failed")
child_item = _build_queue_item(
item_id=11,
session=child_session,
user_id="user-1",
status="failed",
root_item_id=root_item.item_id,
)
items = {root_item.item_id: root_item, child_item.item_id: child_item}
monkeypatch.setattr(session_queue, "get_queue_item", lambda item_id: items[item_id])
retry_result = session_queue.retry_items_by_id("default", [child_item.item_id, root_item.item_id])
assert retry_result.retried_item_ids == [root_item.item_id]
all_items = session_queue.list_all_queue_items("default")
retried_items = [item for item in all_items if item.retried_from_item_id == root_item.item_id]
assert len(retried_items) == 1
assert retried_items[0].status == "pending"
assert retried_items[0].workflow_call_id is None
assert retried_items[0].parent_item_id is None
assert retried_items[0].root_item_id is None
retry_events = [event for event in event_bus.events if isinstance(event, QueueItemsRetriedEvent)]
assert len(retry_events) == 1
assert retry_events[0].retried_item_ids == [root_item.item_id]
assert retry_events[0].user_ids == ["user-1"]
assert retry_events[0].retried_item_ids_by_user == {"user-1": [root_item.item_id]}
def test_retry_items_by_id_emits_unique_owner_ids_for_multiple_roots(
session_queue: SqliteSessionQueue, event_bus: TestEventService, monkeypatch: pytest.MonkeyPatch
) -> None:
first_root_item = _build_queue_item(
item_id=20, session=GraphExecutionState(graph=Graph()), user_id="user-1", status="failed"
)
second_root_item = _build_queue_item(
item_id=21, session=GraphExecutionState(graph=Graph()), user_id="user-2", status="canceled"
)
items = {
first_root_item.item_id: first_root_item,
second_root_item.item_id: second_root_item,
}
monkeypatch.setattr(session_queue, "get_queue_item", lambda item_id: items[item_id])
retry_result = session_queue.retry_items_by_id("default", [first_root_item.item_id, second_root_item.item_id])
assert retry_result.retried_item_ids == [first_root_item.item_id, second_root_item.item_id]
retry_events = [event for event in event_bus.events if isinstance(event, QueueItemsRetriedEvent)]
assert len(retry_events) == 1
assert retry_events[0].user_ids == ["user-1", "user-2"]
assert retry_events[0].retried_item_ids_by_user == {
"user-1": [first_root_item.item_id],
"user-2": [second_root_item.item_id],
}
@@ -0,0 +1,70 @@
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_2026_07_01_add_workflow_call_queue_metadata import (
AddWorkflowCallQueueMetadataCallback,
build_migration,
)
def _get_columns(cursor: sqlite3.Cursor, table_name: str) -> set[str]:
cursor.execute(f"PRAGMA table_info({table_name});")
return {row[1] for row in cursor.fetchall()}
def _get_indexes(cursor: sqlite3.Cursor) -> set[str]:
cursor.execute("SELECT name FROM sqlite_master WHERE type = 'index';")
return {row[0] for row in cursor.fetchall()}
def test_adds_workflow_call_columns_and_indexes_to_session_queue() -> None:
db = sqlite3.connect(":memory:")
cursor = db.cursor()
cursor.execute("CREATE TABLE session_queue (item_id INTEGER PRIMARY KEY);")
AddWorkflowCallQueueMetadataCallback()(cursor)
assert _get_columns(cursor, "session_queue") >= {
"workflow_call_id",
"parent_item_id",
"parent_session_id",
"root_item_id",
"workflow_call_depth",
}
assert _get_indexes(cursor) >= {
"idx_session_queue_workflow_call_id",
"idx_session_queue_parent_item_id",
"idx_session_queue_parent_session_id",
"idx_session_queue_root_item_id",
"idx_session_queue_workflow_call_depth",
}
db.close()
def test_migration_is_idempotent_and_tolerates_missing_session_queue() -> None:
db = sqlite3.connect(":memory:")
cursor = db.cursor()
AddWorkflowCallQueueMetadataCallback()(cursor)
cursor.execute("CREATE TABLE session_queue (item_id INTEGER PRIMARY KEY, workflow_call_id TEXT);")
AddWorkflowCallQueueMetadataCallback()(cursor)
AddWorkflowCallQueueMetadataCallback()(cursor)
assert _get_columns(cursor, "session_queue") >= {
"workflow_call_id",
"parent_item_id",
"parent_session_id",
"root_item_id",
"workflow_call_depth",
}
db.close()
def test_build_migration_declares_stable_id_and_dependency() -> None:
migration = build_migration()
assert migration.id == "2026_07_01_add_workflow_call_queue_metadata"
assert migration.depends_on == "migration_33"
assert migration.from_version is None
assert migration.to_version is None
@@ -0,0 +1,91 @@
"""Tests for migration 31: Add image_subfolder column to images table."""
import sqlite3
import pytest
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_31 import (
Migration31Callback,
build_migration_31,
)
@pytest.fixture
def db() -> sqlite3.Connection:
"""In-memory SQLite database with a minimal images table mimicking pre-migration schema."""
conn = sqlite3.connect(":memory:")
conn.execute(
"""
CREATE TABLE images (
image_name TEXT NOT NULL PRIMARY KEY,
image_origin TEXT NOT NULL,
image_category TEXT NOT NULL,
width INTEGER NOT NULL DEFAULT 0,
height INTEGER NOT NULL DEFAULT 0,
session_id TEXT,
node_id TEXT,
metadata TEXT,
is_intermediate BOOLEAN DEFAULT FALSE,
created_at DATETIME NOT NULL DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%f', 'NOW')),
updated_at DATETIME NOT NULL DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%f', 'NOW')),
deleted_at DATETIME,
starred BOOLEAN NOT NULL DEFAULT FALSE,
has_workflow BOOLEAN NOT NULL DEFAULT FALSE
);
"""
)
return conn
class TestMigration31:
def test_adds_image_subfolder_column(self, db: sqlite3.Connection):
"""Migration adds image_subfolder column to existing images table."""
callback = Migration31Callback()
cursor = db.cursor()
callback(cursor)
cursor.execute("PRAGMA table_info(images);")
columns = {row[1] for row in cursor.fetchall()}
assert "image_subfolder" in columns
def test_existing_rows_get_empty_string_default(self, db: sqlite3.Connection):
"""Pre-existing image rows should get image_subfolder = '' after migration."""
db.execute(
"INSERT INTO images (image_name, image_origin, image_category, width, height, has_workflow) "
"VALUES ('old_image.png', 'internal', 'general', 512, 512, 0)"
)
db.commit()
callback = Migration31Callback()
callback(db.cursor())
db.commit()
row = db.execute("SELECT image_subfolder FROM images WHERE image_name = 'old_image.png'").fetchone()
assert row is not None
assert row[0] == ""
def test_idempotent_migration(self, db: sqlite3.Connection):
"""Running migration twice should not fail (column already exists)."""
callback = Migration31Callback()
cursor = db.cursor()
callback(cursor)
# Running again should be safe
callback(cursor)
cursor.execute("PRAGMA table_info(images);")
columns = [row[1] for row in cursor.fetchall()]
assert columns.count("image_subfolder") == 1
def test_no_images_table_is_noop(self):
"""If images table doesn't exist, migration is a no-op."""
conn = sqlite3.connect(":memory:")
callback = Migration31Callback()
cursor = conn.cursor()
# Should not raise
callback(cursor)
def test_build_migration_31_version_numbers(self):
"""build_migration_31 returns correct version range."""
migration = build_migration_31()
assert migration.from_version == 30
assert migration.to_version == 31
@@ -0,0 +1,131 @@
"""Tests for migration 32: Repair model_relationships foreign keys."""
import sqlite3
import pytest
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_32 import (
Migration32Callback,
build_migration_32,
)
def _create_models_table(conn: sqlite3.Connection) -> None:
conn.execute(
"""
CREATE TABLE models (
id TEXT NOT NULL PRIMARY KEY,
config TEXT NOT NULL
);
"""
)
def _create_broken_relationships_table(conn: sqlite3.Connection) -> None:
"""Recreates the broken state left by migration 22: FKs reference the dropped models_old table."""
conn.execute(
"""
CREATE TABLE model_relationships (
model_key_1 TEXT NOT NULL,
model_key_2 TEXT NOT NULL,
created_at TEXT DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
PRIMARY KEY (model_key_1, model_key_2),
FOREIGN KEY (model_key_1) REFERENCES "models_old"(id) ON DELETE CASCADE,
FOREIGN KEY (model_key_2) REFERENCES "models_old"(id) ON DELETE CASCADE
);
"""
)
conn.execute("CREATE INDEX keyx_model_relationships_model_key_2 ON model_relationships(model_key_2);")
@pytest.fixture
def db() -> sqlite3.Connection:
conn = sqlite3.connect(":memory:")
_create_models_table(conn)
_create_broken_relationships_table(conn)
conn.execute("INSERT INTO models (id, config) VALUES ('a', '{}'), ('b', '{}'), ('c', '{}')")
return conn
class TestMigration32:
def test_repoints_foreign_keys_to_models(self, db: sqlite3.Connection):
"""After migration, the foreign keys reference models, not models_old."""
Migration32Callback()(db.cursor())
db.commit()
sql = db.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='model_relationships'").fetchone()[
0
]
assert "models_old" not in sql
assert "REFERENCES models(id)" in sql
def test_preserves_valid_links(self, db: sqlite3.Connection):
"""Links between existing models are preserved."""
db.execute("INSERT INTO model_relationships (model_key_1, model_key_2) VALUES ('a', 'b')")
db.commit()
Migration32Callback()(db.cursor())
db.commit()
rows = db.execute("SELECT model_key_1, model_key_2 FROM model_relationships ORDER BY model_key_1").fetchall()
assert rows == [("a", "b")]
def test_drops_orphaned_links(self, db: sqlite3.Connection):
"""Links referencing missing models are dropped so the restored FKs are satisfiable."""
db.execute("INSERT INTO model_relationships (model_key_1, model_key_2) VALUES ('a', 'b')")
db.execute("INSERT INTO model_relationships (model_key_1, model_key_2) VALUES ('a', 'gone')")
db.commit()
Migration32Callback()(db.cursor())
db.commit()
rows = db.execute("SELECT model_key_1, model_key_2 FROM model_relationships").fetchall()
assert rows == [("a", "b")]
def test_cascade_works_after_repair(self, db: sqlite3.Connection):
"""ON DELETE CASCADE against models works once the FKs are repaired."""
db.execute("INSERT INTO model_relationships (model_key_1, model_key_2) VALUES ('a', 'b')")
db.commit()
Migration32Callback()(db.cursor())
db.commit()
db.execute("PRAGMA foreign_keys = ON;")
db.execute("DELETE FROM models WHERE id = 'a'")
db.commit()
rows = db.execute("SELECT * FROM model_relationships").fetchall()
assert rows == []
def test_index_recreated(self, db: sqlite3.Connection):
"""The lookup index on model_key_2 is recreated on the rebuilt table."""
Migration32Callback()(db.cursor())
db.commit()
idx = db.execute(
"SELECT name FROM sqlite_master WHERE type='index' AND name='keyx_model_relationships_model_key_2'"
).fetchone()
assert idx is not None
def test_idempotent_when_already_correct(self, db: sqlite3.Connection):
"""Running on an already-correct table is a no-op (no rebuild)."""
Migration32Callback()(db.cursor())
db.commit()
# Second run should detect the correct FKs and do nothing.
Migration32Callback()(db.cursor())
db.commit()
sql = db.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='model_relationships'").fetchone()[
0
]
assert "REFERENCES models(id)" in sql
def test_no_relationships_table_is_noop(self):
"""If the table doesn't exist, migration is a no-op."""
conn = sqlite3.connect(":memory:")
Migration32Callback()(conn.cursor()) # should not raise
def test_build_migration_32_version_numbers(self):
migration = build_migration_32()
assert migration.from_version == 31
assert migration.to_version == 32
@@ -0,0 +1,252 @@
import importlib
from logging import Logger
from pathlib import Path
import pytest
from invokeai.app.services.shared.sqlite_migrator.migration_loader import (
MigrationBuildContext,
MigrationLoaderError,
build_migrations,
)
def _write_package(tmp_path: Path, package_name: str, modules: dict[str, str]) -> str:
package_path = tmp_path / package_name
package_path.mkdir()
(package_path / "__init__.py").write_text("", encoding="utf-8")
for module_name, module_source in modules.items():
(package_path / f"{module_name}.py").write_text(module_source, encoding="utf-8")
importlib.invalidate_caches()
return package_name
def test_build_migrations_discovers_modules_in_numeric_order(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
package_name = _write_package(
tmp_path,
"test_migrations_order",
{
"migration_2": """
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
def build_migration_2():
return Migration(from_version=1, to_version=2, callback=lambda cursor: None)
""",
"migration_1": """
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
def build_migration_1():
return Migration(from_version=0, to_version=1, callback=lambda cursor: None)
""",
"not_a_migration": "VALUE = 1",
},
)
monkeypatch.syspath_prepend(str(tmp_path))
migrations = build_migrations(
MigrationBuildContext(app_config=object(), logger=Logger("test"), image_files=object()),
package_name=package_name,
)
assert [migration.id for migration in migrations] == ["migration_1", "migration_2"]
def test_build_migrations_injects_requested_dependencies(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
package_name = _write_package(
tmp_path,
"test_migrations_injection",
{
"migration_1": """
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
def build_migration_1(app_config, logger, image_files):
assert app_config == "config"
assert logger.name == "test"
assert image_files == "images"
return Migration(from_version=0, to_version=1, callback=lambda cursor: None)
""",
},
)
monkeypatch.syspath_prepend(str(tmp_path))
migrations = build_migrations(
MigrationBuildContext(app_config="config", logger=Logger("test"), image_files="images"),
package_name=package_name,
)
assert [migration.id for migration in migrations] == ["migration_1"]
def test_build_migrations_supports_dated_descriptive_modules(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
package_name = _write_package(
tmp_path,
"test_migrations_dated",
{
"migration_2026_06_30_add_example_table": """
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
def build_migration():
return Migration(
id="2026_06_30_add_example_table",
depends_on="migration_1",
callback=lambda cursor: None,
)
""",
"migration_1": """
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
def build_migration_1():
return Migration(from_version=0, to_version=1, callback=lambda cursor: None)
""",
},
)
monkeypatch.syspath_prepend(str(tmp_path))
migrations = build_migrations(
MigrationBuildContext(app_config=object(), logger=Logger("test"), image_files=object()),
package_name=package_name,
)
assert [migration.id for migration in migrations] == ["migration_1", "2026_06_30_add_example_table"]
def test_build_migrations_rejects_dated_module_id_mismatch(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
package_name = _write_package(
tmp_path,
"test_migrations_dated_id_mismatch",
{
"migration_2026_06_30_add_example_table": """
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
def build_migration():
return Migration(
id="2026_06_30_typo",
depends_on="migration_1",
callback=lambda cursor: None,
)
""",
},
)
monkeypatch.syspath_prepend(str(tmp_path))
with pytest.raises(MigrationLoaderError, match="must return migration id"):
build_migrations(
MigrationBuildContext(app_config=object(), logger=Logger("test"), image_files=object()),
package_name=package_name,
)
def test_build_migrations_rejects_numeric_module_id_mismatch(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
package_name = _write_package(
tmp_path,
"test_migrations_numeric_id_mismatch",
{
"migration_1": """
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
def build_migration_1():
return Migration(id="wrong", from_version=0, to_version=1, callback=lambda cursor: None)
""",
},
)
monkeypatch.syspath_prepend(str(tmp_path))
with pytest.raises(MigrationLoaderError, match="must return migration id"):
build_migrations(
MigrationBuildContext(app_config=object(), logger=Logger("test"), image_files=object()),
package_name=package_name,
)
def test_build_migrations_rejects_unknown_builder_dependency(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
package_name = _write_package(
tmp_path,
"test_migrations_unknown_dependency",
{
"migration_1": """
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
def build_migration_1(unknown_service):
return Migration(from_version=0, to_version=1, callback=lambda cursor: None)
""",
},
)
monkeypatch.syspath_prepend(str(tmp_path))
with pytest.raises(MigrationLoaderError, match="unknown dependency"):
build_migrations(
MigrationBuildContext(app_config=object(), logger=Logger("test"), image_files=object()),
package_name=package_name,
)
def test_build_migrations_rejects_missing_expected_builder(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
package_name = _write_package(
tmp_path,
"test_migrations_missing_builder",
{
"migration_1": """
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
def build_something_else():
return Migration(from_version=0, to_version=1, callback=lambda cursor: None)
""",
},
)
monkeypatch.syspath_prepend(str(tmp_path))
with pytest.raises(MigrationLoaderError, match="build_migration_1"):
build_migrations(
MigrationBuildContext(app_config=object(), logger=Logger("test"), image_files=object()),
package_name=package_name,
)
def test_build_migrations_rejects_non_migration_return(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
package_name = _write_package(
tmp_path,
"test_migrations_bad_return",
{
"migration_1": """
def build_migration_1():
return object()
""",
},
)
monkeypatch.syspath_prepend(str(tmp_path))
with pytest.raises(MigrationLoaderError, match="must return Migration"):
build_migrations(
MigrationBuildContext(app_config=object(), logger=Logger("test"), image_files=object()),
package_name=package_name,
)
def test_build_migrations_rejects_malformed_migration_module(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
package_name = _write_package(
tmp_path,
"test_migrations_malformed",
{
"migration_latest": "VALUE = 1",
},
)
monkeypatch.syspath_prepend(str(tmp_path))
with pytest.raises(MigrationLoaderError, match="Malformed migration module"):
build_migrations(
MigrationBuildContext(app_config=object(), logger=Logger("test"), image_files=object()),
package_name=package_name,
)
def test_build_migrations_discovers_production_migrations(tmp_path: Path) -> None:
class FakeConfig:
root_path = tmp_path
models_path = tmp_path / "models"
convert_cache_path = tmp_path / "models" / ".cache"
legacy_conf_path = tmp_path / "models.yaml"
legacy_conf_dir = tmp_path
migrations = build_migrations(
MigrationBuildContext(app_config=FakeConfig(), logger=Logger("test"), image_files=object())
)
legacy_migrations = [migration for migration in migrations if migration.to_version is not None]
latest_legacy_version = max(migration.to_version or 0 for migration in legacy_migrations)
assert [migration.id for migration in legacy_migrations] == [
f"migration_{i}" for i in range(1, latest_legacy_version + 1)
]
assert [migration.depends_on for migration in legacy_migrations] == [None] + [
f"migration_{i}" for i in range(1, latest_legacy_version)
]
@@ -0,0 +1,76 @@
from unittest.mock import MagicMock
from invokeai.app.services.invocation_services import InvocationServices
from invokeai.app.services.invoker import Invoker
from invokeai.app.services.session_processor.session_processor_default import DefaultSessionProcessor
def _services(**overrides):
services = {
"board_image_records": object(),
"board_images": object(),
"board_records": object(),
"boards": object(),
"bulk_download": object(),
"configuration": object(),
"events": object(),
"images": object(),
"image_files": object(),
"image_records": object(),
"logger": object(),
"model_images": object(),
"model_manager": object(),
"model_relationships": object(),
"model_relationship_records": object(),
"download_queue": object(),
"external_generation": object(),
"performance_statistics": object(),
"session_queue": object(),
"session_processor": object(),
"invocation_cache": object(),
"names": object(),
"urls": object(),
"workflow_records": object(),
"tensors": object(),
"conditioning": object(),
"style_preset_records": object(),
"style_preset_image_files": object(),
"workflow_thumbnails": object(),
"client_state_persistence": object(),
"users": object(),
"image_moves": None,
}
services.update(overrides)
return InvocationServices(**services)
def test_image_moves_start_before_session_processor() -> None:
started: list[str] = []
image_moves = MagicMock()
image_moves.start.side_effect = lambda _invoker: started.append("image_moves")
session_processor = MagicMock()
session_processor.start.side_effect = lambda _invoker: started.append("session_processor")
Invoker(_services(image_moves=image_moves, session_processor=session_processor))
assert started == ["image_moves", "session_processor"]
def test_session_processor_detects_active_image_move_maintenance() -> None:
image_moves = MagicMock()
image_moves.is_maintenance_active.return_value = True
processor = DefaultSessionProcessor()
processor._invoker = MagicMock()
processor._invoker.services.image_moves = image_moves
assert processor._is_image_move_maintenance_active() is True
def test_session_processor_allows_processing_without_image_move_maintenance() -> None:
image_moves = MagicMock()
image_moves.is_maintenance_active.return_value = False
processor = DefaultSessionProcessor()
processor._invoker = MagicMock()
processor._invoker.services.image_moves = image_moves
assert processor._is_image_move_maintenance_active() is False
@@ -0,0 +1,13 @@
from tests.app.services import workflow_call_test_utils as workflow_call_tests
def test_run_node_propagates_keyboard_interrupt(monkeypatch) -> None:
workflow_call_tests.test_run_node_propagates_keyboard_interrupt(monkeypatch)
def test_run_node_does_not_swallow_sigint_in_subprocess() -> None:
workflow_call_tests.test_run_node_does_not_swallow_sigint_in_subprocess()
def test_on_after_run_session_does_not_complete_incomplete_session(monkeypatch) -> None:
workflow_call_tests.test_on_after_run_session_does_not_complete_incomplete_session(monkeypatch)
@@ -0,0 +1,64 @@
import pytest
from invokeai.app.services.board_records.board_records_common import (
BoardRecordNotFoundException,
BoardRecordOrderBy,
)
from invokeai.app.services.board_records.board_records_sqlite import SqliteBoardRecordStorage
from invokeai.app.services.config.config_default import InvokeAIAppConfig
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
from invokeai.backend.util.logging import InvokeAILogger
from tests.fixtures.sqlite_database import create_mock_sqlite_database
def _create_board_storage() -> SqliteBoardRecordStorage:
config = InvokeAIAppConfig(use_memory_db=True)
db = create_mock_sqlite_database(config=config, logger=InvokeAILogger.get_logger())
return SqliteBoardRecordStorage(db=db)
def test_sql_injection_payload_in_board_name_is_stored_as_plain_text() -> None:
storage = _create_board_storage()
payload = "name'); DROP TABLE boards; --"
injected_board = storage.save(payload, "0")
fetched = storage.get(injected_board.board_id)
assert fetched.board_name == payload
another_board = storage.save("safe board", "0")
assert storage.get(another_board.board_id).board_name == "safe board"
def test_sql_injection_payload_in_board_id_does_not_bypass_where_clause() -> None:
storage = _create_board_storage()
storage.save("first board", "0")
storage.save("second board", "0")
payload = "does-not-exist' OR '1'='1"
with pytest.raises(BoardRecordNotFoundException):
storage.get(payload)
def test_sql_injection_payload_in_delete_does_not_delete_other_rows() -> None:
storage = _create_board_storage()
first = storage.save("first board", "0")
second = storage.save("second board", "0")
payload = f"{first.board_id}' OR '1'='1"
storage.delete(payload)
remaining = storage.get_many(
order_by=BoardRecordOrderBy.CreatedAt,
direction=SQLiteDirection.Ascending,
limit=10,
offset=0,
include_archived=True,
user_id="0",
is_admin=True,
)
assert {board.board_id for board in remaining.items} == {first.board_id, second.board_id}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,33 @@
from tests.app.services import workflow_call_test_utils as workflow_call_tests
def test_run_node_fails_cleanly_for_invalid_batch_child_workflow(monkeypatch) -> None:
workflow_call_tests.test_run_node_fails_cleanly_for_invalid_batch_child_workflow(monkeypatch)
def test_run_completes_call_saved_workflow_with_batched_child_returns(monkeypatch) -> None:
workflow_call_tests.test_run_completes_call_saved_workflow_with_batched_child_returns(monkeypatch)
def test_run_zips_grouped_batch_children(monkeypatch) -> None:
workflow_call_tests.test_run_zips_grouped_batch_children(monkeypatch)
def test_run_expands_ungrouped_batch_children_as_cartesian_product(monkeypatch) -> None:
workflow_call_tests.test_run_expands_ungrouped_batch_children_as_cartesian_product(monkeypatch)
def test_run_fails_batched_child_workflow_and_cancels_remaining_siblings(monkeypatch) -> None:
workflow_call_tests.test_run_fails_batched_child_workflow_and_cancels_remaining_siblings(monkeypatch)
def test_run_supports_generator_backed_integer_batched_child_workflow(monkeypatch) -> None:
workflow_call_tests.test_run_supports_generator_backed_integer_batched_child_workflow(monkeypatch)
def test_run_supports_generator_backed_image_batched_child_workflow(monkeypatch) -> None:
workflow_call_tests.test_run_supports_generator_backed_image_batched_child_workflow(monkeypatch)
def test_run_rejects_non_generator_connected_batched_child_workflow(monkeypatch) -> None:
workflow_call_tests.test_run_rejects_non_generator_connected_batched_child_workflow(monkeypatch)
@@ -0,0 +1,613 @@
from typing import Any
from invokeai.app.services.shared.workflow_call_compatibility import (
WorkflowCallCompatibilityReason,
get_workflow_call_compatibility,
)
def _invocation_node(node_id: str, invocation_type: str, inputs: dict[str, Any]) -> dict[str, Any]:
return {
"id": node_id,
"type": "invocation",
"position": {"x": 0, "y": 0},
"data": {
"id": node_id,
"type": invocation_type,
"version": "1.0.0",
"nodePack": "invokeai",
"label": "",
"notes": "",
"isOpen": True,
"isIntermediate": False,
"useCache": True,
"dynamicInputTemplates": {},
"inputs": inputs,
},
}
def _workflow_dump(*, nodes: list[dict[str, Any]], edges: list[dict[str, Any]]) -> dict[str, Any]:
return {
"name": "Child Workflow",
"author": "Tester",
"description": "",
"version": "1.0.0",
"contact": "",
"tags": "",
"notes": "",
"exposedFields": [],
"meta": {"category": "user", "version": "1.0.0"},
"nodes": nodes,
"edges": edges,
"form": None,
}
def _return_nodes() -> list[dict[str, Any]]:
return [
_invocation_node(
"return-value", "workflow_return_value", {"key": {"value": "result"}, "value": {"value": None}}
),
_invocation_node("return-collect", "collect", {"collection": {"value": []}}),
_invocation_node("return", "workflow_return", {"values": {"value": []}}),
]
def _return_edges(source: str, source_handle: str) -> list[dict[str, str]]:
return [
{
"id": "edge-return-value",
"type": "default",
"source": source,
"sourceHandle": source_handle,
"target": "return-value",
"targetHandle": "value",
},
{
"id": "edge-return-collect",
"type": "default",
"source": "return-value",
"sourceHandle": "value",
"target": "return-collect",
"targetHandle": "item",
},
{
"id": "edge-return-values",
"type": "default",
"source": "return-collect",
"sourceHandle": "collection",
"target": "return",
"targetHandle": "values",
},
]
def _services():
return type(
"Services",
(),
{
"board_images": type(
"BoardImages",
(),
{
"get_all_board_image_names_for_board": staticmethod(
lambda board_id, categories, is_intermediate: ["img-a", "img-b"]
)
},
)(),
},
)()
def _services_that_fail_on_image_enumeration():
def fail(*args: Any, **kwargs: Any) -> list[str]:
raise AssertionError("image names should not be enumerated for structural compatibility")
return type(
"Services",
(),
{
"board_images": type(
"BoardImages",
(),
{"get_all_board_image_names_for_board": staticmethod(fail)},
)(),
},
)()
def test_get_workflow_call_compatibility_returns_ok_for_simple_callable_workflow() -> None:
workflow = _workflow_dump(
nodes=[
_invocation_node("collection", "integer_collection", {"collection": {"value": [1]}}),
*_return_nodes(),
],
edges=_return_edges("collection", "collection"),
)
compatibility = get_workflow_call_compatibility(
workflow=workflow,
workflow_id="workflow-a",
services=_services(),
user_id="user-1",
maximum_children=1000,
)
assert compatibility.is_callable is True
assert compatibility.reason is WorkflowCallCompatibilityReason.Ok
assert compatibility.message is None
def test_get_workflow_call_compatibility_allows_legacy_none_board_values() -> None:
workflow = _workflow_dump(
nodes=[
_invocation_node(
"image",
"blank_image",
{"board": {"value": "none"}, "width": {"value": 64}, "height": {"value": 64}},
),
*_return_nodes(),
],
edges=_return_edges("image", "image"),
)
compatibility = get_workflow_call_compatibility(
workflow=workflow,
workflow_id="workflow-a",
services=_services(),
user_id="user-1",
maximum_children=1000,
)
assert compatibility.is_callable is True
assert compatibility.reason is WorkflowCallCompatibilityReason.Ok
assert compatibility.message is None
def test_get_workflow_call_compatibility_allows_single_return_value_connected_directly() -> None:
workflow = _workflow_dump(
nodes=[
_invocation_node("sum", "add", {"a": {"value": 1}, "b": {"value": 2}}),
_invocation_node(
"return-value", "workflow_return_value", {"key": {"value": "sum"}, "value": {"value": None}}
),
_invocation_node("return", "workflow_return", {"values": {"value": []}}),
],
edges=[
{
"id": "edge-sum-return-value",
"type": "default",
"source": "sum",
"sourceHandle": "value",
"target": "return-value",
"targetHandle": "value",
},
{
"id": "edge-return-value-return",
"type": "default",
"source": "return-value",
"sourceHandle": "value",
"target": "return",
"targetHandle": "values",
},
],
)
compatibility = get_workflow_call_compatibility(
workflow=workflow,
workflow_id="workflow-a",
services=_services(),
user_id="user-1",
maximum_children=1000,
)
assert compatibility.is_callable is True
assert compatibility.reason is WorkflowCallCompatibilityReason.Ok
assert compatibility.message is None
def test_get_workflow_call_compatibility_reports_missing_workflow_return() -> None:
workflow = _workflow_dump(nodes=[_invocation_node("add", "add", {"a": {"value": 1}, "b": {"value": 2}})], edges=[])
compatibility = get_workflow_call_compatibility(
workflow=workflow,
workflow_id="workflow-a",
services=_services(),
user_id="user-1",
maximum_children=1000,
)
assert compatibility.is_callable is False
assert compatibility.reason is WorkflowCallCompatibilityReason.MissingWorkflowReturn
assert compatibility.message == "The workflow must contain exactly one workflow_return node."
def test_get_workflow_call_compatibility_reports_multiple_workflow_return_nodes() -> None:
workflow = _workflow_dump(
nodes=[
_invocation_node("return-a", "workflow_return", {"values": {"value": []}}),
_invocation_node("return-b", "workflow_return", {"values": {"value": []}}),
],
edges=[],
)
compatibility = get_workflow_call_compatibility(
workflow=workflow,
workflow_id="workflow-a",
services=_services(),
user_id="user-1",
maximum_children=1000,
)
assert compatibility.is_callable is False
assert compatibility.reason is WorkflowCallCompatibilityReason.MultipleWorkflowReturn
assert compatibility.message == "The workflow must not contain more than one workflow_return node."
def test_get_workflow_call_compatibility_reports_generator_capacity_limit() -> None:
workflow = _workflow_dump(
nodes=[
_invocation_node(
"generator",
"integer_generator",
{
"generator": {
"value": {
"type": "integer_generator_arithmetic_sequence",
"start": 0,
"step": 1,
"count": 1_000_000,
}
}
},
),
_invocation_node(
"batch",
"integer_batch",
{"integers": {"value": []}, "batch_group_id": {"value": "None"}},
),
_invocation_node("target", "integer", {"value": {"value": 0}}),
*_return_nodes(),
],
edges=[
{
"id": "edge-generator-batch",
"type": "default",
"source": "generator",
"sourceHandle": "integers",
"target": "batch",
"targetHandle": "integers",
},
{
"id": "edge-batch-target",
"type": "default",
"source": "batch",
"sourceHandle": "integers",
"target": "target",
"targetHandle": "value",
},
*_return_edges("target", "value"),
],
)
compatibility = get_workflow_call_compatibility(
workflow=workflow,
workflow_id="workflow-a",
services=_services(),
user_id="user-1",
maximum_children=10,
)
assert compatibility.is_callable is False
assert compatibility.reason is WorkflowCallCompatibilityReason.ExceedsCapacity
assert compatibility.message == "call_saved_workflow exceeds remaining queue capacity for child workflow executions"
def test_get_workflow_call_compatibility_does_not_report_present_malformed_workflow_return_as_missing() -> None:
workflow = _workflow_dump(
nodes=[
{
"id": "return",
"type": "invocation",
"position": {"x": 0, "y": 0},
"data": {
"type": "workflow_return",
"version": "1.0.0",
"nodePack": "invokeai",
"label": "",
"notes": "",
"isOpen": True,
"isIntermediate": False,
"useCache": True,
"dynamicInputTemplates": {},
"inputs": {"values": {"value": []}},
},
}
],
edges=[],
)
compatibility = get_workflow_call_compatibility(
workflow=workflow,
workflow_id="workflow-a",
services=_services(),
user_id="user-1",
maximum_children=1000,
)
assert compatibility.is_callable is False
assert compatibility.reason is WorkflowCallCompatibilityReason.InvalidGraph
assert compatibility.message != "The workflow must contain exactly one workflow_return node."
def test_get_workflow_call_compatibility_reports_unsupported_connected_batch_input() -> None:
workflow = _workflow_dump(
nodes=[
_invocation_node("source", "integer", {"value": {"value": 7}}),
_invocation_node(
"batch", "integer_batch", {"integers": {"value": []}, "batch_group_id": {"value": "None"}}
),
_invocation_node("target", "integer", {"value": {"value": 0}}),
_invocation_node("collect", "collect", {"collection": {"value": []}}),
*_return_nodes(),
],
edges=[
{
"id": "edge-source-batch",
"type": "default",
"source": "source",
"sourceHandle": "value",
"target": "batch",
"targetHandle": "integers",
},
{
"id": "edge-batch-target",
"type": "default",
"source": "batch",
"sourceHandle": "integers",
"target": "target",
"targetHandle": "value",
},
{
"id": "edge-target-collect",
"type": "default",
"source": "target",
"sourceHandle": "value",
"target": "collect",
"targetHandle": "item",
},
*_return_edges("collect", "collection"),
],
)
compatibility = get_workflow_call_compatibility(
workflow=workflow,
workflow_id="workflow-a",
services=_services(),
user_id="user-1",
maximum_children=1000,
)
assert compatibility.is_callable is False
assert compatibility.reason is WorkflowCallCompatibilityReason.UnsupportedBatchInput
assert "connected batch child workflow inputs" in (compatibility.message or "")
def test_get_workflow_call_compatibility_allows_batch_returned_by_name() -> None:
workflow = _workflow_dump(
nodes=[
_invocation_node(
"batch", "integer_batch", {"integers": {"value": [2, 4]}, "batch_group_id": {"value": "None"}}
),
*_return_nodes(),
],
edges=[
{
"id": "edge-batch-return",
"type": "default",
"source": "batch",
"sourceHandle": "integers",
"target": "return-value",
"targetHandle": "value",
},
*_return_edges("return-value", "value")[1:],
],
)
compatibility = get_workflow_call_compatibility(
workflow=workflow,
workflow_id="workflow-a",
services=_services(),
user_id="user-1",
maximum_children=1000,
)
assert compatibility.is_callable is True
assert compatibility.reason is WorkflowCallCompatibilityReason.Ok
assert compatibility.message is None
def test_get_workflow_call_compatibility_can_skip_generator_expansion_for_list_views() -> None:
workflow = _workflow_dump(
nodes=[
_invocation_node(
"generator",
"image_generator",
{
"generator": {
"value": {
"type": "image_generator_images_from_board",
"board_id": "board-a",
"category": "images",
}
}
},
),
_invocation_node("batch", "image_batch", {"images": {"value": []}, "batch_group_id": {"value": "None"}}),
*_return_nodes(),
],
edges=[
{
"id": "edge-generator-batch",
"type": "default",
"source": "generator",
"sourceHandle": "collection",
"target": "batch",
"targetHandle": "images",
},
{
"id": "edge-batch-return",
"type": "default",
"source": "batch",
"sourceHandle": "images",
"target": "return-value",
"targetHandle": "value",
},
*_return_edges("return-value", "value")[1:],
],
)
compatibility = get_workflow_call_compatibility(
workflow=workflow,
workflow_id="workflow-a",
services=_services_that_fail_on_image_enumeration(),
user_id="user-1",
maximum_children=1000,
resolve_generator_items=False,
)
assert compatibility.is_callable is True
assert compatibility.reason is WorkflowCallCompatibilityReason.Ok
assert compatibility.message is None
def test_get_workflow_call_compatibility_reports_multiple_batch_inputs_as_unsupported_batch_input() -> None:
workflow = _workflow_dump(
nodes=[
_invocation_node("source-a", "integer", {"value": {"value": 7}}),
_invocation_node("source-b", "integer", {"value": {"value": 8}}),
_invocation_node(
"batch", "integer_batch", {"integers": {"value": []}, "batch_group_id": {"value": "None"}}
),
_invocation_node("target", "integer", {"value": {"value": 0}}),
_invocation_node("collect", "collect", {"collection": {"value": []}}),
*_return_nodes(),
],
edges=[
{
"id": "edge-source-a-batch",
"type": "default",
"source": "source-a",
"sourceHandle": "value",
"target": "batch",
"targetHandle": "integers",
},
{
"id": "edge-source-b-batch",
"type": "default",
"source": "source-b",
"sourceHandle": "value",
"target": "batch",
"targetHandle": "integers",
},
{
"id": "edge-batch-target",
"type": "default",
"source": "batch",
"sourceHandle": "integers",
"target": "target",
"targetHandle": "value",
},
{
"id": "edge-target-collect",
"type": "default",
"source": "target",
"sourceHandle": "value",
"target": "collect",
"targetHandle": "item",
},
*_return_edges("collect", "collection"),
],
)
compatibility = get_workflow_call_compatibility(
workflow=workflow,
workflow_id="workflow-a",
services=_services(),
user_id="user-1",
maximum_children=1000,
)
assert compatibility.is_callable is False
assert compatibility.reason is WorkflowCallCompatibilityReason.UnsupportedBatchInput
assert "multiple connected batch inputs" in (compatibility.message or "")
def test_get_workflow_call_compatibility_allows_workflow_with_required_exposed_input() -> None:
workflow = _workflow_dump(
nodes=[
_invocation_node("target", "integer", {"value": {}}),
_invocation_node("collect", "collect", {"collection": {"value": []}}),
*_return_nodes(),
],
edges=[
{
"id": "edge-target-collect",
"type": "default",
"source": "target",
"sourceHandle": "value",
"target": "collect",
"targetHandle": "item",
},
*_return_edges("collect", "collection"),
],
)
workflow["exposedFields"] = [{"nodeId": "target", "fieldName": "value"}]
compatibility = get_workflow_call_compatibility(
workflow=workflow,
workflow_id="workflow-a",
services=_services(),
user_id="user-1",
maximum_children=1000,
)
assert compatibility.is_callable is True
assert compatibility.reason is WorkflowCallCompatibilityReason.Ok
assert compatibility.message is None
def test_get_workflow_call_compatibility_allows_workflow_with_required_structured_exposed_input() -> None:
workflow = _workflow_dump(
nodes=[
_invocation_node("template", "prompt_template", {"style_preset": {}}),
_invocation_node("collect", "collect", {"collection": {"value": []}}),
*_return_nodes(),
],
edges=[
{
"id": "edge-template-collect",
"type": "default",
"source": "template",
"sourceHandle": "positive_prompt",
"target": "collect",
"targetHandle": "item",
},
*_return_edges("collect", "collection"),
],
)
workflow["exposedFields"] = [{"nodeId": "template", "fieldName": "style_preset"}]
compatibility = get_workflow_call_compatibility(
workflow=workflow,
workflow_id="workflow-a",
services=_services(),
user_id="user-1",
maximum_children=1000,
)
assert compatibility.is_callable is True
assert compatibility.reason is WorkflowCallCompatibilityReason.Ok
assert compatibility.message is None
@@ -0,0 +1,153 @@
from tests.app.services import workflow_call_test_utils as workflow_call_tests
def test_run_node_enters_waiting_state_without_executing_child_inline(monkeypatch) -> None:
workflow_call_tests.test_run_node_enters_waiting_state_without_executing_child_inline(monkeypatch)
def test_run_persists_waiting_session_without_completing_queue_item(monkeypatch) -> None:
workflow_call_tests.test_run_persists_waiting_session_without_completing_queue_item(monkeypatch)
def test_workflow_call_coordinator_suspends_parent_and_enqueues_child_queue_item(monkeypatch) -> None:
workflow_call_tests.test_workflow_call_coordinator_suspends_parent_and_enqueues_child_queue_item(monkeypatch)
def test_workflow_call_queue_lifecycle_leaves_non_call_workflows_on_normal_execution_path(monkeypatch) -> None:
workflow_call_tests.test_workflow_call_queue_lifecycle_leaves_non_call_workflows_on_normal_execution_path(
monkeypatch
)
def test_default_session_processor_uses_runner_workflow_call_lifecycle(monkeypatch) -> None:
workflow_call_tests.test_default_session_processor_uses_runner_workflow_call_lifecycle(monkeypatch)
def test_workflow_call_queue_lifecycle_resumes_parent_from_completed_child(monkeypatch) -> None:
workflow_call_tests.test_workflow_call_queue_lifecycle_resumes_parent_from_completed_child(monkeypatch)
def test_run_preserves_canceled_child_workflow_chain_without_failing_parent(monkeypatch) -> None:
workflow_call_tests.test_run_preserves_canceled_child_workflow_chain_without_failing_parent(monkeypatch)
def test_run_does_not_resume_canceled_parent_after_completed_child(monkeypatch) -> None:
workflow_call_tests.test_run_does_not_resume_canceled_parent_after_completed_child(monkeypatch)
def test_run_does_not_fail_canceled_parent_after_child_return_error(monkeypatch) -> None:
workflow_call_tests.test_run_does_not_fail_canceled_parent_after_child_return_error(monkeypatch)
def test_run_queue_item_tolerates_queue_item_deleted_mid_run(monkeypatch) -> None:
workflow_call_tests.test_run_queue_item_tolerates_queue_item_deleted_mid_run(monkeypatch)
def test_run_queue_item_tolerates_parent_deleted_while_child_runs(monkeypatch) -> None:
workflow_call_tests.test_run_queue_item_tolerates_parent_deleted_while_child_runs(monkeypatch)
def test_run_queue_item_tolerates_parent_deleted_before_completed_parent_mutation(monkeypatch) -> None:
workflow_call_tests.test_run_queue_item_tolerates_parent_deleted_before_completed_parent_mutation(monkeypatch)
def test_run_queue_item_tolerates_parent_deleted_before_failed_parent_mutation(monkeypatch) -> None:
workflow_call_tests.test_run_queue_item_tolerates_parent_deleted_before_failed_parent_mutation(monkeypatch)
def test_run_queue_item_tolerates_parent_deleted_before_canceled_parent_mutation(monkeypatch) -> None:
workflow_call_tests.test_run_queue_item_tolerates_parent_deleted_before_canceled_parent_mutation(monkeypatch)
def test_workflow_call_coordinator_builds_child_queue_item_with_relationship_metadata(monkeypatch) -> None:
workflow_call_tests.test_workflow_call_coordinator_builds_child_queue_item_with_relationship_metadata(monkeypatch)
def test_workflow_call_coordinator_cleans_up_enqueued_children_when_boundary_setup_fails(monkeypatch) -> None:
workflow_call_tests.test_workflow_call_coordinator_cleans_up_enqueued_children_when_boundary_setup_fails(
monkeypatch
)
def test_workflow_call_coordinator_rejects_child_expansion_that_exceeds_remaining_queue_capacity(monkeypatch) -> None:
workflow_call_tests.test_workflow_call_coordinator_rejects_child_expansion_that_exceeds_remaining_queue_capacity(
monkeypatch
)
def test_run_completes_call_saved_workflow_and_runs_downstream_nodes(monkeypatch) -> None:
workflow_call_tests.test_run_completes_call_saved_workflow_and_runs_downstream_nodes(monkeypatch)
def test_run_completes_parent_queue_item_when_return_get_is_terminal(monkeypatch) -> None:
workflow_call_tests.test_run_completes_parent_queue_item_when_return_get_is_terminal(monkeypatch)
def test_run_node_records_child_execution_state_for_call_saved_workflow(monkeypatch) -> None:
workflow_call_tests.test_run_node_records_child_execution_state_for_call_saved_workflow(monkeypatch)
def test_run_executes_child_workflow_and_completes_parent_queue_item(monkeypatch) -> None:
workflow_call_tests.test_run_executes_child_workflow_and_completes_parent_queue_item(monkeypatch)
def test_run_completes_call_saved_workflow_with_child_return_collection(monkeypatch) -> None:
workflow_call_tests.test_run_completes_call_saved_workflow_with_child_return_collection(monkeypatch)
def test_run_extracts_named_call_saved_workflow_return(monkeypatch) -> None:
workflow_call_tests.test_run_extracts_named_call_saved_workflow_return(monkeypatch)
def test_workflow_call_batch_aggregation_rejects_inconsistent_return_keys() -> None:
workflow_call_tests.test_workflow_call_batch_aggregation_rejects_inconsistent_return_keys()
def test_workflow_call_return_aggregation_failure_cancels_remaining_siblings(monkeypatch) -> None:
workflow_call_tests.test_workflow_call_return_aggregation_failure_cancels_remaining_siblings(monkeypatch)
def test_run_fails_call_saved_workflow_when_child_has_no_workflow_return(monkeypatch) -> None:
workflow_call_tests.test_run_fails_call_saved_workflow_when_child_has_no_workflow_return(monkeypatch)
def test_run_respects_child_dependency_readiness(monkeypatch) -> None:
workflow_call_tests.test_run_respects_child_dependency_readiness(monkeypatch)
def test_run_respects_child_if_branching(monkeypatch) -> None:
workflow_call_tests.test_run_respects_child_if_branching(monkeypatch)
def test_run_supports_nested_call_saved_workflow_execution(monkeypatch) -> None:
workflow_call_tests.test_run_supports_nested_call_saved_workflow_execution(monkeypatch)
def test_run_cascades_nested_child_workflow_failures_to_all_parents(monkeypatch) -> None:
workflow_call_tests.test_run_cascades_nested_child_workflow_failures_to_all_parents(monkeypatch)
def test_run_forwards_literal_dynamic_workflow_inputs_to_child_workflow(monkeypatch) -> None:
workflow_call_tests.test_run_forwards_literal_dynamic_workflow_inputs_to_child_workflow(monkeypatch)
def test_run_forwards_connected_dynamic_workflow_inputs_to_child_workflow(monkeypatch) -> None:
workflow_call_tests.test_run_forwards_connected_dynamic_workflow_inputs_to_child_workflow(monkeypatch)
def test_run_rejects_non_exposed_dynamic_workflow_inputs(monkeypatch) -> None:
workflow_call_tests.test_run_rejects_non_exposed_dynamic_workflow_inputs(monkeypatch)
def test_run_fails_call_saved_workflow_when_child_workflow_graph_cannot_be_built(monkeypatch) -> None:
workflow_call_tests.test_run_fails_call_saved_workflow_when_child_workflow_graph_cannot_be_built(monkeypatch)
def test_run_fails_call_saved_workflow_with_invalid_selection_without_entering_waiting_state(monkeypatch) -> None:
workflow_call_tests.test_run_fails_call_saved_workflow_with_invalid_selection_without_entering_waiting_state(
monkeypatch
)
def test_run_fails_call_saved_workflow_when_depth_limit_is_exceeded(monkeypatch) -> None:
workflow_call_tests.test_run_fails_call_saved_workflow_when_depth_limit_is_exceeded(monkeypatch)
@@ -0,0 +1,270 @@
import pytest
from invokeai.app.services.shared.graph import Graph
from invokeai.app.services.shared.workflow_graph_builder import (
UnsupportedWorkflowNodeError,
build_graph_from_workflow,
)
def _build_workflow_node(
node_id: str,
invocation_type: str,
inputs: dict[str, object],
*,
is_intermediate: bool = False,
use_cache: bool = True,
):
return {
"id": node_id,
"type": "invocation",
"position": {"x": 0, "y": 0},
"data": {
"id": node_id,
"type": invocation_type,
"version": "1.0.0",
"nodePack": "invokeai",
"label": "",
"notes": "",
"isOpen": True,
"isIntermediate": is_intermediate,
"useCache": use_cache,
"dynamicInputTemplates": {},
"inputs": {name: {"value": value} for name, value in inputs.items()},
},
}
def _build_connector_node(node_id: str):
return {
"id": node_id,
"type": "connector",
"position": {"x": 0, "y": 0},
"data": {
"id": node_id,
"type": "connector",
"label": "Connector",
"isOpen": True,
},
}
def _build_workflow(edges: list[dict], nodes: list[dict]):
return {
"name": "Child Workflow",
"author": "Tester",
"description": "",
"version": "1.0.0",
"contact": "",
"tags": "",
"notes": "",
"exposedFields": [],
"meta": {"version": "1.0.0", "category": "user"},
"nodes": nodes,
"edges": edges,
"form": None,
}
def _build_named_return_nodes():
return [
_build_workflow_node("return-value-1", "workflow_return_value", {"key": "result", "value": None}),
_build_workflow_node("return-collect-1", "collect", {"collection": []}),
_build_workflow_node("return-1", "workflow_return", {"values": []}),
]
def _build_named_return_edges(source: str, source_handle: str):
return [
{
"id": "edge-return-value",
"type": "default",
"source": source,
"sourceHandle": source_handle,
"target": "return-value-1",
"targetHandle": "value",
},
{
"id": "edge-return-collect",
"type": "default",
"source": "return-value-1",
"sourceHandle": "value",
"target": "return-collect-1",
"targetHandle": "item",
},
{
"id": "edge-return-values",
"type": "default",
"source": "return-collect-1",
"sourceHandle": "collection",
"target": "return-1",
"targetHandle": "values",
},
]
def test_build_graph_from_workflow_converts_invocation_nodes():
workflow = _build_workflow(
nodes=[
_build_workflow_node("add-1", "add", {"a": 1, "b": 2}),
_build_workflow_node("return-1", "workflow_return", {"values": []}),
],
edges=[],
)
graph = build_graph_from_workflow(workflow)
assert isinstance(graph, Graph)
assert set(graph.nodes.keys()) == {"add-1", "return-1"}
assert graph.nodes["add-1"].get_type() == "add"
assert graph.nodes["add-1"].a == 1
assert graph.nodes["add-1"].b == 2
assert graph.nodes["return-1"].get_type() == "workflow_return"
def test_build_graph_from_workflow_flattens_connector_edges():
workflow = _build_workflow(
nodes=[
_build_workflow_node("add-1", "add", {"a": 1, "b": 2}),
_build_connector_node("connector-1"),
_build_workflow_node("add-2", "add", {"a": 999, "b": 3}),
*_build_named_return_nodes(),
],
edges=[
{
"id": "edge-1",
"type": "default",
"source": "add-1",
"sourceHandle": "value",
"target": "connector-1",
"targetHandle": "in",
},
{
"id": "edge-2",
"type": "default",
"source": "connector-1",
"sourceHandle": "out",
"target": "add-2",
"targetHandle": "a",
},
*_build_named_return_edges("add-2", "value"),
],
)
graph = build_graph_from_workflow(workflow)
assert len(graph.edges) == 4
first_edge, second_edge, third_edge, fourth_edge = graph.edges
assert first_edge.source.node_id == "add-1"
assert first_edge.source.field == "value"
assert first_edge.destination.node_id == "add-2"
assert first_edge.destination.field == "a"
assert second_edge.source.node_id == "add-2"
assert second_edge.source.field == "value"
assert second_edge.destination.node_id == "return-value-1"
assert second_edge.destination.field == "value"
assert third_edge.destination.node_id == "return-collect-1"
assert third_edge.destination.field == "item"
assert fourth_edge.destination.node_id == "return-1"
assert fourth_edge.destination.field == "values"
assert graph.nodes["add-2"].a == 0
assert graph.nodes["add-2"].b == 3
assert graph.nodes["return-1"].values == []
def test_build_graph_from_workflow_uses_defaults_for_inputs_without_saved_values():
collect_node = _build_workflow_node("return-collect-1", "collect", {})
collect_node["data"]["inputs"] = {
"item": {"name": "item", "label": "", "description": ""},
"collection": {"name": "collection", "label": "", "description": ""},
}
workflow = _build_workflow(
nodes=[
_build_workflow_node("return-value-1", "workflow_return_value", {"key": "result", "value": None}),
collect_node,
_build_workflow_node("return-1", "workflow_return", {"values": []}),
],
edges=[
{
"id": "edge-return-collect",
"type": "default",
"source": "return-value-1",
"sourceHandle": "value",
"target": "return-collect-1",
"targetHandle": "item",
},
{
"id": "edge-return-values",
"type": "default",
"source": "return-collect-1",
"sourceHandle": "collection",
"target": "return-1",
"targetHandle": "values",
},
],
)
graph = build_graph_from_workflow(workflow)
assert graph.nodes["return-collect-1"].collection == []
def test_build_graph_from_workflow_uses_default_for_legacy_auto_board_values():
workflow = _build_workflow(
nodes=[
_build_workflow_node("image-1", "blank_image", {"board": "auto", "width": 64, "height": 64}),
*_build_named_return_nodes(),
],
edges=_build_named_return_edges("image-1", "image"),
)
graph = build_graph_from_workflow(workflow)
assert graph.nodes["image-1"].board is None
def test_build_graph_from_workflow_uses_default_for_legacy_none_board_values():
workflow = _build_workflow(
nodes=[
_build_workflow_node("image-1", "blank_image", {"board": "none", "width": 64, "height": 64}),
*_build_named_return_nodes(),
],
edges=_build_named_return_edges("image-1", "image"),
)
graph = build_graph_from_workflow(workflow)
assert graph.nodes["image-1"].board is None
def test_build_graph_from_workflow_rejects_batch_special_nodes_with_clear_error():
workflow = _build_workflow(
nodes=[_build_workflow_node("image-batch-1", "image_batch", {"images": []})],
edges=[],
)
with pytest.raises(UnsupportedWorkflowNodeError, match="call_saved_workflow does not yet support batch-special"):
build_graph_from_workflow(workflow)
def test_build_graph_from_workflow_rejects_workflows_without_workflow_return():
workflow = _build_workflow(
nodes=[_build_workflow_node("add-1", "add", {"a": 1, "b": 2})],
edges=[],
)
with pytest.raises(UnsupportedWorkflowNodeError, match="exactly one workflow_return"):
build_graph_from_workflow(workflow)
def test_build_graph_from_workflow_rejects_workflows_with_multiple_workflow_return_nodes():
workflow = _build_workflow(
nodes=[
_build_workflow_node("return-1", "workflow_return", {"values": []}),
_build_workflow_node("return-2", "workflow_return", {"values": []}),
],
edges=[],
)
with pytest.raises(UnsupportedWorkflowNodeError, match="exactly one workflow_return"):
build_graph_from_workflow(workflow)
@@ -0,0 +1,56 @@
"""Tests for password utilities."""
from invokeai.app.services.auth.password_utils import hash_password, validate_password_strength, verify_password
def test_hash_password():
"""Test password hashing."""
password = "TestPassword123"
hashed = hash_password(password)
assert hashed != password
assert len(hashed) > 0
def test_verify_password():
"""Test password verification."""
password = "TestPassword123"
hashed = hash_password(password)
assert verify_password(password, hashed)
assert not verify_password("WrongPassword", hashed)
def test_validate_password_strength_valid():
"""Test password strength validation with valid passwords."""
valid, msg = validate_password_strength("ValidPass123")
assert valid
assert msg == ""
def test_validate_password_strength_too_short():
"""Test password strength validation with short password."""
valid, msg = validate_password_strength("Pass1")
assert not valid
assert "at least 8 characters" in msg
def test_validate_password_strength_no_uppercase():
"""Test password strength validation without uppercase."""
valid, msg = validate_password_strength("password123")
assert not valid
assert "uppercase" in msg.lower()
def test_validate_password_strength_no_lowercase():
"""Test password strength validation without lowercase."""
valid, msg = validate_password_strength("PASSWORD123")
assert not valid
assert "lowercase" in msg.lower()
def test_validate_password_strength_no_digit():
"""Test password strength validation without digit."""
valid, msg = validate_password_strength("PasswordTest")
assert not valid
assert "number" in msg.lower()
@@ -0,0 +1,43 @@
"""Tests for token service."""
from datetime import timedelta
from invokeai.app.services.auth.token_service import TokenData, create_access_token, verify_token
def test_create_access_token():
"""Test creating an access token."""
data = TokenData(user_id="test-user", email="test@example.com", is_admin=False)
token = create_access_token(data)
assert token is not None
assert len(token) > 0
def test_verify_valid_token():
"""Test verifying a valid token."""
data = TokenData(user_id="test-user", email="test@example.com", is_admin=True)
token = create_access_token(data)
verified_data = verify_token(token)
assert verified_data is not None
assert verified_data.user_id == data.user_id
assert verified_data.email == data.email
assert verified_data.is_admin == data.is_admin
def test_verify_invalid_token():
"""Test verifying an invalid token."""
verified_data = verify_token("invalid-token")
assert verified_data is None
def test_token_with_custom_expiration():
"""Test creating token with custom expiration."""
data = TokenData(user_id="test-user", email="test@example.com", is_admin=False)
token = create_access_token(data, expires_delta=timedelta(hours=1))
verified_data = verify_token(token)
assert verified_data is not None
assert verified_data.user_id == data.user_id
@@ -0,0 +1,272 @@
"""Tests for user service."""
from logging import Logger
import pytest
from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
from invokeai.app.services.users.users_common import UserCreateRequest, UserUpdateRequest
from invokeai.app.services.users.users_default import UserService
@pytest.fixture
def logger() -> Logger:
"""Create a logger for testing."""
return Logger("test_user_service")
@pytest.fixture
def db(logger: Logger) -> SqliteDatabase:
"""Create an in-memory database for testing."""
db = SqliteDatabase(db_path=None, logger=logger, verbose=False)
# Create users table manually for testing
db._conn.execute("""
CREATE TABLE users (
user_id TEXT NOT NULL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
display_name TEXT,
password_hash TEXT NOT NULL,
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
last_login_at DATETIME
);
""")
db._conn.commit()
return db
@pytest.fixture
def user_service(db: SqliteDatabase) -> UserService:
"""Create a user service for testing."""
return UserService(db)
def test_create_user(user_service: UserService):
"""Test creating a user."""
user_data = UserCreateRequest(
email="test@example.com",
display_name="Test User",
password="TestPassword123",
is_admin=False,
)
user = user_service.create(user_data)
assert user.email == "test@example.com"
assert user.display_name == "Test User"
assert user.is_admin is False
assert user.is_active is True
assert user.user_id is not None
def test_create_user_weak_password(user_service: UserService):
"""Test creating a user with weak password fails when strict checking is enabled."""
user_data = UserCreateRequest(
email="test@example.com",
display_name="Test User",
password="weak",
is_admin=False,
)
with pytest.raises(ValueError, match="at least 8 characters"):
user_service.create(user_data, strict_password_checking=True)
def test_create_user_weak_password_non_strict(user_service: UserService):
"""Test creating a user with weak password succeeds when strict checking is disabled."""
user_data = UserCreateRequest(
email="weakpass@example.com",
display_name="Test User",
password="weak",
is_admin=False,
)
user = user_service.create(user_data, strict_password_checking=False)
assert user.email == "weakpass@example.com"
def test_create_duplicate_user(user_service: UserService):
"""Test creating a duplicate user."""
user_data = UserCreateRequest(
email="test@example.com",
display_name="Test User",
password="TestPassword123",
is_admin=False,
)
user_service.create(user_data)
with pytest.raises(ValueError, match="already exists"):
user_service.create(user_data)
def test_get_user(user_service: UserService):
"""Test getting a user by ID."""
user_data = UserCreateRequest(
email="test@example.com",
display_name="Test User",
password="TestPassword123",
)
created_user = user_service.create(user_data)
retrieved_user = user_service.get(created_user.user_id)
assert retrieved_user is not None
assert retrieved_user.user_id == created_user.user_id
assert retrieved_user.email == created_user.email
def test_get_nonexistent_user(user_service: UserService):
"""Test getting a nonexistent user."""
user = user_service.get("nonexistent-id")
assert user is None
def test_get_user_by_email(user_service: UserService):
"""Test getting a user by email."""
user_data = UserCreateRequest(
email="test@example.com",
display_name="Test User",
password="TestPassword123",
)
created_user = user_service.create(user_data)
retrieved_user = user_service.get_by_email("test@example.com")
assert retrieved_user is not None
assert retrieved_user.user_id == created_user.user_id
assert retrieved_user.email == "test@example.com"
def test_update_user(user_service: UserService):
"""Test updating a user."""
user_data = UserCreateRequest(
email="test@example.com",
display_name="Test User",
password="TestPassword123",
)
user = user_service.create(user_data)
updates = UserUpdateRequest(
display_name="Updated Name",
is_admin=True,
)
updated_user = user_service.update(user.user_id, updates)
assert updated_user.display_name == "Updated Name"
assert updated_user.is_admin is True
def test_delete_user(user_service: UserService):
"""Test deleting a user."""
user_data = UserCreateRequest(
email="test@example.com",
display_name="Test User",
password="TestPassword123",
)
user = user_service.create(user_data)
user_service.delete(user.user_id)
retrieved_user = user_service.get(user.user_id)
assert retrieved_user is None
def test_authenticate_valid_credentials(user_service: UserService):
"""Test authenticating with valid credentials."""
user_data = UserCreateRequest(
email="test@example.com",
display_name="Test User",
password="TestPassword123",
)
user_service.create(user_data)
authenticated_user = user_service.authenticate("test@example.com", "TestPassword123")
assert authenticated_user is not None
assert authenticated_user.email == "test@example.com"
assert authenticated_user.last_login_at is not None
def test_authenticate_invalid_password(user_service: UserService):
"""Test authenticating with invalid password."""
user_data = UserCreateRequest(
email="test@example.com",
display_name="Test User",
password="TestPassword123",
)
user_service.create(user_data)
authenticated_user = user_service.authenticate("test@example.com", "WrongPassword")
assert authenticated_user is None
def test_authenticate_nonexistent_user(user_service: UserService):
"""Test authenticating nonexistent user."""
authenticated_user = user_service.authenticate("nonexistent@example.com", "TestPassword123")
assert authenticated_user is None
def test_has_admin(user_service: UserService):
"""Test checking if admin exists."""
assert user_service.has_admin() is False
user_data = UserCreateRequest(
email="admin@example.com",
display_name="Admin User",
password="AdminPassword123",
is_admin=True,
)
user_service.create(user_data)
assert user_service.has_admin() is True
def test_create_admin(user_service: UserService):
"""Test creating an admin user."""
user_data = UserCreateRequest(
email="admin@example.com",
display_name="Admin User",
password="AdminPassword123",
)
admin = user_service.create_admin(user_data)
assert admin.is_admin is True
assert admin.email == "admin@example.com"
def test_create_admin_when_exists(user_service: UserService):
"""Test creating admin when one already exists."""
user_data = UserCreateRequest(
email="admin@example.com",
display_name="Admin User",
password="AdminPassword123",
)
user_service.create_admin(user_data)
with pytest.raises(ValueError, match="already exists"):
user_service.create_admin(user_data)
def test_list_users(user_service: UserService):
"""Test listing users."""
for i in range(5):
user_data = UserCreateRequest(
email=f"test{i}@example.com",
display_name=f"Test User {i}",
password="TestPassword123",
)
user_service.create(user_data)
users = user_service.list_users()
assert len(users) == 5
limited_users = user_service.list_users(limit=2)
assert len(limited_users) == 2
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,223 @@
import json
import logging
from unittest.mock import MagicMock, patch
import pytest
from PIL import Image
from invokeai.app.api.extract_metadata_from_image import ExtractedMetadata, extract_metadata_from_image
@pytest.fixture
def mock_logger():
return MagicMock(spec=logging.Logger)
@pytest.fixture
def valid_metadata():
return json.dumps({"param1": "value1", "param2": 123})
@pytest.fixture
def valid_workflow():
return json.dumps({"name": "test_workflow", "version": "1.0"})
@pytest.fixture
def valid_graph():
return json.dumps({"nodes": {}, "edges": []})
def test_extract_valid_metadata_from_image(mock_logger, valid_metadata, valid_workflow, valid_graph):
# Create a mock image with valid metadata
mock_image = MagicMock(spec=Image.Image)
mock_image.info = {
"invokeai_metadata": valid_metadata,
"invokeai_workflow": valid_workflow,
"invokeai_graph": valid_graph,
}
# Mock the validation functions
with patch(
"invokeai.app.services.workflow_records.workflow_records_common.WorkflowWithoutIDValidator.validate_json"
) as mock_workflow_validate:
with patch("invokeai.app.services.shared.graph.Graph.model_validate_json") as _mock_graph_validate:
result = extract_metadata_from_image(mock_image, None, None, None, mock_logger)
# Assert correct calls to validators
mock_workflow_validate.assert_called_once_with(valid_workflow)
# TODO(psyche): The extract_metadata_from_image does not validate the graph correctly. See note in `extract_metadata_from_image.py`.
# Skipping this.
# _mock_graph_validate.assert_called_once_with(valid_graph)
# Assert correct extraction
assert result == ExtractedMetadata(
invokeai_metadata=valid_metadata, invokeai_workflow=valid_workflow, invokeai_graph=valid_graph
)
def test_extract_invalid_metadata(mock_logger, valid_workflow, valid_graph):
# Invalid metadata (not JSON)
invalid_metadata = "not a valid json"
mock_image = MagicMock(spec=Image.Image)
mock_image.info = {
"invokeai_metadata": invalid_metadata,
"invokeai_workflow": valid_workflow,
"invokeai_graph": valid_graph,
}
with patch(
"invokeai.app.services.workflow_records.workflow_records_common.WorkflowWithoutIDValidator.validate_json"
):
with patch("invokeai.app.services.shared.graph.Graph.model_validate_json"):
result = extract_metadata_from_image(mock_image, None, None, None, mock_logger)
assert mock_logger.debug.to_have_been_called_with("Failed to parse metadata for uploaded image")
# Invalid metadata should be None, others valid
assert result.invokeai_metadata is None
assert result.invokeai_workflow == valid_workflow
assert result.invokeai_graph == valid_graph
def test_metadata_wrong_type(mock_logger, valid_workflow, valid_graph):
# Valid JSON but not a dict
metadata_array = json.dumps(["item1", "item2"])
mock_image = MagicMock(spec=Image.Image)
mock_image.info = {
"invokeai_metadata": metadata_array,
"invokeai_workflow": valid_workflow,
"invokeai_graph": valid_graph,
}
with patch(
"invokeai.app.services.workflow_records.workflow_records_common.WorkflowWithoutIDValidator.validate_json"
):
with patch("invokeai.app.services.shared.graph.Graph.model_validate_json"):
result = extract_metadata_from_image(mock_image, None, None, None, mock_logger)
# Metadata should be None as it's not a dict
assert result.invokeai_metadata is None
assert result.invokeai_workflow == valid_workflow
assert result.invokeai_graph == valid_graph
def test_with_non_string_metadata(mock_logger, valid_workflow, valid_graph):
# Some implementations might include metadata as non-string values
mock_image = MagicMock(spec=Image.Image)
mock_image.info = {
"invokeai_metadata": 12345, # Not a string
"invokeai_workflow": valid_workflow,
"invokeai_graph": valid_graph,
}
with patch(
"invokeai.app.services.workflow_records.workflow_records_common.WorkflowWithoutIDValidator.validate_json"
):
with patch("invokeai.app.services.shared.graph.Graph.model_validate_json"):
result = extract_metadata_from_image(mock_image, None, None, None, mock_logger)
assert mock_logger.debug.to_have_been_called_with("Failed to parse metadata for uploaded image")
assert result.invokeai_metadata is None
assert result.invokeai_workflow == valid_workflow
assert result.invokeai_graph == valid_graph
def test_invalid_workflow(mock_logger, valid_metadata, valid_graph):
invalid_workflow = "not a valid workflow json"
mock_image = MagicMock(spec=Image.Image)
mock_image.info = {
"invokeai_metadata": valid_metadata,
"invokeai_workflow": invalid_workflow,
"invokeai_graph": valid_graph,
}
with patch(
"invokeai.app.services.workflow_records.workflow_records_common.WorkflowWithoutIDValidator.validate_json"
) as mock_validate:
mock_validate.side_effect = ValueError("Invalid workflow")
with patch("invokeai.app.services.shared.graph.Graph.model_validate_json"):
result = extract_metadata_from_image(mock_image, None, None, None, mock_logger)
assert result.invokeai_metadata == valid_metadata
assert result.invokeai_workflow is None
assert result.invokeai_graph == valid_graph
def test_invalid_graph(mock_logger, valid_metadata, valid_workflow):
invalid_graph = "not a valid graph json"
mock_image = MagicMock(spec=Image.Image)
mock_image.info = {
"invokeai_metadata": valid_metadata,
"invokeai_workflow": valid_workflow,
"invokeai_graph": invalid_graph,
}
with patch(
"invokeai.app.services.workflow_records.workflow_records_common.WorkflowWithoutIDValidator.validate_json"
):
with patch("invokeai.app.services.shared.graph.Graph.model_validate_json") as mock_validate:
mock_validate.side_effect = ValueError("Invalid graph")
result = extract_metadata_from_image(mock_image, None, None, None, mock_logger)
assert result.invokeai_metadata == valid_metadata
assert result.invokeai_workflow == valid_workflow
assert result.invokeai_graph is None
def test_with_overrides(mock_logger, valid_metadata, valid_workflow, valid_graph):
# Different values in the image
mock_image = MagicMock(spec=Image.Image)
# When overrides are provided, they should be used instead of the values in the image, we shouldn'teven try
# to parse the values in the image
mock_image.info = {
"invokeai_metadata": 12345,
"invokeai_workflow": 12345,
"invokeai_graph": 12345,
}
with patch(
"invokeai.app.services.workflow_records.workflow_records_common.WorkflowWithoutIDValidator.validate_json"
):
with patch("invokeai.app.services.shared.graph.Graph.model_validate_json"):
result = extract_metadata_from_image(mock_image, valid_metadata, valid_workflow, valid_graph, mock_logger)
# Override values should be used
assert result.invokeai_metadata == valid_metadata
assert result.invokeai_workflow == valid_workflow
assert result.invokeai_graph == valid_graph
def test_with_no_metadata(mock_logger):
# Image with no metadata
mock_image = MagicMock(spec=Image.Image)
mock_image.info = {}
result = extract_metadata_from_image(mock_image, None, None, None, mock_logger)
assert result.invokeai_metadata is None
assert result.invokeai_workflow is None
assert result.invokeai_graph is None
def test_empty_string_overrides_do_not_fall_back_to_image_metadata(
mock_logger, valid_metadata, valid_workflow, valid_graph
):
mock_image = MagicMock(spec=Image.Image)
mock_image.info = {
"invokeai_metadata": valid_metadata,
"invokeai_workflow": valid_workflow,
"invokeai_graph": valid_graph,
}
result = extract_metadata_from_image(mock_image, "", "", "", mock_logger)
assert result.invokeai_metadata is None
assert result.invokeai_workflow is None
assert result.invokeai_graph is None
+180
View File
@@ -0,0 +1,180 @@
from types import SimpleNamespace
from unittest.mock import ANY, AsyncMock
import pytest
from fastapi import FastAPI
from invokeai.app.api.sockets import SocketIO
@pytest.fixture
def anyio_backend() -> str:
return "asyncio"
def _patch_multiuser_context(monkeypatch: pytest.MonkeyPatch, *, user_id: str, is_admin: bool) -> None:
user = SimpleNamespace(user_id=user_id, is_active=True)
invoker = SimpleNamespace(
services=SimpleNamespace(
configuration=SimpleNamespace(multiuser=True),
users=SimpleNamespace(get=lambda candidate_user_id: user if candidate_user_id == user_id else None),
)
)
monkeypatch.setattr("invokeai.app.api.dependencies.ApiDependencies", SimpleNamespace(invoker=invoker))
monkeypatch.setattr(
"invokeai.app.api.sockets.verify_token",
lambda token: SimpleNamespace(user_id=user_id, is_admin=is_admin) if token == "valid-token" else None,
)
def _patch_single_user_context(monkeypatch: pytest.MonkeyPatch) -> None:
invoker = SimpleNamespace(services=SimpleNamespace(configuration=SimpleNamespace(multiuser=False)))
monkeypatch.setattr("invokeai.app.api.dependencies.ApiDependencies", SimpleNamespace(invoker=invoker))
@pytest.mark.anyio
async def test_authenticated_user_joins_workflow_rooms_on_connect(monkeypatch: pytest.MonkeyPatch) -> None:
socketio = SocketIO(FastAPI())
socketio._sio.enter_room = AsyncMock()
_patch_multiuser_context(monkeypatch, user_id="user-1", is_admin=False)
accepted = await socketio._handle_connect("sid-1", {}, {"token": "valid-token"})
assert accepted is True
socketio._sio.enter_room.assert_any_call("sid-1", "user:user-1")
socketio._sio.enter_room.assert_any_call("sid-1", "workflows:shared")
@pytest.mark.anyio
async def test_admin_joins_admin_room_on_connect(monkeypatch: pytest.MonkeyPatch) -> None:
socketio = SocketIO(FastAPI())
socketio._sio.enter_room = AsyncMock()
_patch_multiuser_context(monkeypatch, user_id="admin-1", is_admin=True)
accepted = await socketio._handle_connect("sid-1", {}, {"token": "valid-token"})
assert accepted is True
socketio._sio.enter_room.assert_any_call("sid-1", "user:admin-1")
socketio._sio.enter_room.assert_any_call("sid-1", "workflows:shared")
socketio._sio.enter_room.assert_any_call("sid-1", "admin")
@pytest.mark.anyio
async def test_single_user_socket_joins_workflow_rooms_on_connect(monkeypatch: pytest.MonkeyPatch) -> None:
socketio = SocketIO(FastAPI())
socketio._sio.enter_room = AsyncMock()
_patch_single_user_context(monkeypatch)
accepted = await socketio._handle_connect("sid-1", {}, None)
assert accepted is True
socketio._sio.enter_room.assert_any_call("sid-1", "user:system")
socketio._sio.enter_room.assert_any_call("sid-1", "workflows:shared")
socketio._sio.enter_room.assert_any_call("sid-1", "admin")
@pytest.mark.anyio
async def test_private_workflow_event_is_emitted_only_to_owner_and_admin() -> None:
socketio = SocketIO(FastAPI())
socketio._sio.emit = AsyncMock()
event_payload = SimpleNamespace(
__event_name__="workflow_created",
workflow_id="wf-1",
user_id="owner-1",
is_public=False,
model_dump=lambda mode="json": {"workflow_id": "wf-1", "user_id": "owner-1", "is_public": False},
)
await socketio._handle_workflow_event(("workflow_created", event_payload))
socketio._sio.emit.assert_any_call(
event="workflow_created",
data={"workflow_id": "wf-1", "user_id": "owner-1", "is_public": False},
room="user:owner-1",
)
socketio._sio.emit.assert_any_call(
event="workflow_created",
data={"workflow_id": "wf-1", "user_id": "owner-1", "is_public": False},
room="admin",
)
assert socketio._sio.emit.await_count == 2
@pytest.mark.anyio
async def test_single_user_workflow_event_is_emitted_once_to_admin_room(monkeypatch: pytest.MonkeyPatch) -> None:
socketio = SocketIO(FastAPI())
socketio._sio.emit = AsyncMock()
_patch_single_user_context(monkeypatch)
event_payload = SimpleNamespace(
__event_name__="workflow_created",
workflow_id="wf-1",
user_id="system",
is_public=False,
model_dump=lambda mode="json": {"workflow_id": "wf-1", "user_id": "system", "is_public": False},
)
await socketio._handle_workflow_event(("workflow_created", event_payload))
socketio._sio.emit.assert_awaited_once_with(
event="workflow_created",
data={"workflow_id": "wf-1", "user_id": "system", "is_public": False},
room="admin",
)
@pytest.mark.anyio
async def test_shared_workflow_event_is_emitted_to_shared_room() -> None:
socketio = SocketIO(FastAPI())
socketio._sio.emit = AsyncMock()
event_payload = SimpleNamespace(
__event_name__="workflow_updated",
workflow_id="wf-1",
user_id="owner-1",
old_is_public=False,
new_is_public=True,
model_dump=lambda mode="json": {
"workflow_id": "wf-1",
"user_id": "owner-1",
"old_is_public": False,
"new_is_public": True,
},
)
await socketio._handle_workflow_event(("workflow_updated", event_payload))
socketio._sio.emit.assert_any_call(
event="workflow_updated",
data={"workflow_id": "wf-1", "user_id": "owner-1", "old_is_public": False, "new_is_public": True},
room="workflows:shared",
)
@pytest.mark.anyio
async def test_shared_to_private_transition_emits_access_revoked_to_shared_room() -> None:
socketio = SocketIO(FastAPI())
socketio._sio.emit = AsyncMock()
event_payload = SimpleNamespace(
__event_name__="workflow_updated",
workflow_id="wf-1",
user_id="owner-1",
old_is_public=True,
new_is_public=False,
model_dump=lambda mode="json": {
"workflow_id": "wf-1",
"user_id": "owner-1",
"old_is_public": True,
"new_is_public": False,
},
)
await socketio._handle_workflow_event(("workflow_updated", event_payload))
socketio._sio.emit.assert_any_call(
event="workflow_access_revoked",
data={"workflow_id": "wf-1", "user_id": "owner-1", "timestamp": ANY},
room="workflows:shared",
)
View File
+50
View File
@@ -0,0 +1,50 @@
import numpy as np
import pytest
from PIL import Image
from invokeai.app.util.controlnet_utils import prepare_control_image
from invokeai.backend.image_util.util import nms
@pytest.mark.parametrize("num_channels", [1, 2, 3])
def test_prepare_control_image_num_channels(num_channels):
"""Test that the `num_channels` parameter is applied correctly in prepare_control_image(...)."""
np_image = np.zeros((256, 256, 3), dtype=np.uint8)
pil_image = Image.fromarray(np_image)
torch_image = prepare_control_image(
image=pil_image,
width=256,
height=256,
num_channels=num_channels,
device="cpu",
do_classifier_free_guidance=False,
)
assert torch_image.shape == (1, num_channels, 256, 256)
@pytest.mark.parametrize("num_channels", [0, 4])
def test_prepare_control_image_num_channels_too_large(num_channels):
"""Test that an exception is raised in prepare_control_image(...) if the `num_channels` parameter is out of the
supported range.
"""
np_image = np.zeros((256, 256, 3), dtype=np.uint8)
pil_image = Image.fromarray(np_image)
with pytest.raises(ValueError):
_ = prepare_control_image(
image=pil_image,
width=256,
height=256,
num_channels=num_channels,
device="cpu",
do_classifier_free_guidance=False,
)
@pytest.mark.parametrize("threshold,sigma", [(None, 1.0), (1, None)])
def test_nms_invalid_options(threshold: None | int, sigma: None | float):
"""Test that an exception is raised in nms(...) if only one of the `threshold` or `sigma` parameters are provided."""
with pytest.raises(ValueError):
nms(np.zeros((256, 256, 3), dtype=np.uint8), threshold, sigma)
+61
View File
@@ -0,0 +1,61 @@
from fastapi import FastAPI
from pydantic import create_model
from invokeai.app.invocations.baseinvocation import InvocationRegistry
from invokeai.app.util.custom_openapi import get_openapi_func
class _FakeOutput:
pass
class _InvocationB:
__name__ = "InvocationB"
@classmethod
def model_json_schema(cls, mode: str, ref_template: str) -> dict:
return {"type": "object", "properties": {}}
@classmethod
def get_output_annotation(cls) -> type:
return _FakeOutput
@classmethod
def get_type(cls) -> str:
return "b_type"
class _InvocationA:
__name__ = "InvocationA"
@classmethod
def model_json_schema(cls, mode: str, ref_template: str) -> dict:
return {"type": "object", "properties": {}}
@classmethod
def get_output_annotation(cls) -> type:
return _FakeOutput
@classmethod
def get_type(cls) -> str:
return "a_type"
def test_invocation_output_map_required_is_sorted(monkeypatch: object) -> None:
"""The 'required' list in InvocationOutputMap must be sorted so that the
generated openapi.json is deterministic regardless of set-iteration order."""
# A FastAPI app needs at least one route to produce a schema with 'components'.
DummyResponse = create_model("DummyResponse", ok=(bool, ...))
app = FastAPI(title="test")
app.get("/healthz", response_model=DummyResponse)(lambda: DummyResponse(ok=True))
monkeypatch.setattr(InvocationRegistry, "get_output_classes", classmethod(lambda cls: [])) # type: ignore[arg-type]
monkeypatch.setattr( # type: ignore[arg-type]
InvocationRegistry, "get_invocation_classes", classmethod(lambda cls: [_InvocationB, _InvocationA])
)
schema = get_openapi_func(app)()
required = schema["components"]["schemas"]["InvocationOutputMap"]["required"]
assert required == ["a_type", "b_type"], f"Expected sorted required list, got: {required}"
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
import pytest
from invokeai.app.util.dynamicprompts import find_missing_wildcards
def test_find_missing_wildcards_detects_unknown_wildcard_in_variant() -> None:
# Regression: `__random__` inside a variant is parsed as a wildcard reference. Left unchecked it
# sends the combinatorial generator into an infinite loop, so it must be reported up front.
assert find_missing_wildcards("{__random__8chan|fenster|stuff}") == ["random"]
def test_find_missing_wildcards_detects_unknown_wildcard_nested_in_sequence_in_variant() -> None:
# The wildcard hangs the generator even when wrapped in other text inside the variant value.
assert find_missing_wildcards("{a __nope__|b}") == ["nope"]
@pytest.mark.parametrize("prompt", ["a __nope__ b", "__nope__", "a photo, __my_style__"])
def test_find_missing_wildcards_ignores_wildcards_outside_variants(prompt: str) -> None:
# A wildcard used as plain literal text generates fine (no hang), so it must not be reported.
assert find_missing_wildcards(prompt) == []
@pytest.mark.parametrize("prompt", ["plain text", "{a|b|c}", "a {2$$x|y|z}"])
def test_find_missing_wildcards_ignores_prompts_without_wildcards(prompt: str) -> None:
assert find_missing_wildcards(prompt) == []
def test_find_missing_wildcards_dedupes_repeated_unknown_wildcards() -> None:
assert find_missing_wildcards("{__nope__|a} {__nope__|b} {__other__|c}") == ["nope", "other"]
+119
View File
@@ -0,0 +1,119 @@
"""Tests for diffusion step callback preview image generation."""
import torch
from PIL import Image
from invokeai.app.util.step_callback import (
QWEN_IMAGE_LATENT_RGB_BIAS,
QWEN_IMAGE_LATENT_RGB_FACTORS,
sample_to_lowres_estimated_image,
)
class TestSampleToLowresEstimatedImage:
"""Test the latent-to-preview-image conversion used during denoising."""
def test_qwen_image_preview_produces_valid_image(self):
"""A synthetic Qwen latent tensor produces a valid RGB preview image."""
# Create a small 1x16x4x4 latent tensor (batch=1, channels=16, 4x4 spatial)
torch.manual_seed(42)
sample = torch.randn(1, 16, 4, 4)
factors = torch.tensor(QWEN_IMAGE_LATENT_RGB_FACTORS, dtype=sample.dtype)
bias = torch.tensor(QWEN_IMAGE_LATENT_RGB_BIAS, dtype=sample.dtype)
image = sample_to_lowres_estimated_image(
samples=sample,
latent_rgb_factors=factors,
latent_rgb_bias=bias,
)
assert isinstance(image, Image.Image)
assert image.size == (4, 4)
assert image.mode == "RGB"
def test_qwen_image_preview_deterministic(self):
"""The same input tensor always produces the same preview image."""
sample = torch.ones(1, 16, 2, 2)
factors = torch.tensor(QWEN_IMAGE_LATENT_RGB_FACTORS, dtype=sample.dtype)
bias = torch.tensor(QWEN_IMAGE_LATENT_RGB_BIAS, dtype=sample.dtype)
image1 = sample_to_lowres_estimated_image(samples=sample, latent_rgb_factors=factors, latent_rgb_bias=bias)
image2 = sample_to_lowres_estimated_image(samples=sample, latent_rgb_factors=factors, latent_rgb_bias=bias)
assert list(image1.getdata()) == list(image2.getdata())
def test_qwen_image_preview_known_value(self):
"""Verify the preview computation against a hand-calculated expected value.
With a 1x16x1x1 tensor of all ones:
- latent_image = [1,1,...,1] @ factors = sum of each column of factors
- R = sum(col 0) = 0.3677, G = sum(col 1) = 0.4577, B = sum(col 2) = 0.9101
- After bias: R = 0.1842, G = 0.3709, B = 0.5741
- After scale ((x+1)/2): R = 0.5921, G = 0.6855, B = 0.7871
- After quantize (*255): R = 151, G = 175, B = 201
"""
sample = torch.ones(1, 16, 1, 1)
factors = torch.tensor(QWEN_IMAGE_LATENT_RGB_FACTORS, dtype=sample.dtype)
bias = torch.tensor(QWEN_IMAGE_LATENT_RGB_BIAS, dtype=sample.dtype)
image = sample_to_lowres_estimated_image(samples=sample, latent_rgb_factors=factors, latent_rgb_bias=bias)
assert image.size == (1, 1)
pixel = image.getpixel((0, 0))
# Compute expected values
col_sums = [sum(row[c] for row in QWEN_IMAGE_LATENT_RGB_FACTORS) for c in range(3)]
expected = []
for c in range(3):
val = col_sums[c] + QWEN_IMAGE_LATENT_RGB_BIAS[c]
val = (val + 1) / 2 # scale from [-1,1] to [0,1]
val = max(0.0, min(1.0, val)) # clamp
expected.append(int(val * 255))
assert pixel == tuple(expected), f"Expected {tuple(expected)}, got {pixel}"
def test_qwen_image_preview_zeros_tensor(self):
"""A zero tensor with bias produces a valid image reflecting just the bias."""
sample = torch.zeros(1, 16, 2, 2)
factors = torch.tensor(QWEN_IMAGE_LATENT_RGB_FACTORS, dtype=sample.dtype)
bias = torch.tensor(QWEN_IMAGE_LATENT_RGB_BIAS, dtype=sample.dtype)
image = sample_to_lowres_estimated_image(samples=sample, latent_rgb_factors=factors, latent_rgb_bias=bias)
assert isinstance(image, Image.Image)
assert image.size == (2, 2)
# All pixels should be identical (uniform zero input)
pixels = [image.getpixel((x, y)) for y in range(image.height) for x in range(image.width)]
assert all(p == pixels[0] for p in pixels)
# With zero input, result = bias, scaled: ((bias + 1) / 2) * 255
expected = []
for c in range(3):
val = (QWEN_IMAGE_LATENT_RGB_BIAS[c] + 1) / 2
val = max(0.0, min(1.0, val))
expected.append(int(val * 255))
assert pixels[0] == tuple(expected)
def test_qwen_image_factors_have_correct_shape(self):
"""Qwen Image uses 16 latent channels, so factors should be 16x3."""
assert len(QWEN_IMAGE_LATENT_RGB_FACTORS) == 16
for row in QWEN_IMAGE_LATENT_RGB_FACTORS:
assert len(row) == 3
assert len(QWEN_IMAGE_LATENT_RGB_BIAS) == 3
def test_3d_input_accepted(self):
"""sample_to_lowres_estimated_image accepts 3D input (no batch dim)."""
sample = torch.randn(16, 4, 4) # no batch dimension
factors = torch.tensor(QWEN_IMAGE_LATENT_RGB_FACTORS, dtype=sample.dtype)
bias = torch.tensor(QWEN_IMAGE_LATENT_RGB_BIAS, dtype=sample.dtype)
image = sample_to_lowres_estimated_image(samples=sample, latent_rgb_factors=factors, latent_rgb_bias=bias)
assert isinstance(image, Image.Image)
assert image.size == (4, 4)
+127
View File
@@ -0,0 +1,127 @@
import pytest
import torch
from tests.dangerously_run_function_in_subprocess import dangerously_run_function_in_subprocess
# These tests are a bit fiddly, because the depend on the import behaviour of torch. They use subprocesses to isolate
# the import behaviour of torch, and then check that the function behaves as expected. We have to hack in some logging
# to check that the tested function is behaving as expected.
@pytest.mark.skipif(not torch.cuda.is_available(), reason="Requires CUDA device.")
def test_configure_torch_cuda_allocator_configures_backend():
"""Test that configure_torch_cuda_allocator() raises a RuntimeError if the configured backend does not match the
expected backend."""
def test_func():
import os
# Unset the environment variable if it is set so that we can test setting it
try:
del os.environ["PYTORCH_CUDA_ALLOC_CONF"]
except KeyError:
pass
from unittest.mock import MagicMock
from invokeai.app.util.torch_cuda_allocator import configure_torch_cuda_allocator
mock_logger = MagicMock()
# Set the PyTorch CUDA memory allocator to cudaMallocAsync
configure_torch_cuda_allocator("backend:cudaMallocAsync", logger=mock_logger)
# Verify that the PyTorch CUDA memory allocator was configured correctly
import torch
assert torch.cuda.get_allocator_backend() == "cudaMallocAsync"
# Verify that the logger was called with the correct message
mock_logger.info.assert_called_once()
args, _kwargs = mock_logger.info.call_args
logged_message = args[0]
print(logged_message)
stdout, _stderr, returncode = dangerously_run_function_in_subprocess(test_func)
assert returncode == 0
assert "PyTorch CUDA memory allocator: cudaMallocAsync" in stdout
@pytest.mark.skipif(not torch.cuda.is_available(), reason="Requires CUDA device.")
def test_configure_torch_cuda_allocator_raises_if_torch_already_imported():
"""Test that configure_torch_cuda_allocator() raises a RuntimeError if torch was already imported."""
def test_func():
from unittest.mock import MagicMock
# Import torch before calling configure_torch_cuda_allocator()
import torch # noqa: F401
from invokeai.app.util.torch_cuda_allocator import configure_torch_cuda_allocator
try:
configure_torch_cuda_allocator("backend:cudaMallocAsync", logger=MagicMock())
except RuntimeError as e:
print(e)
stdout, _stderr, returncode = dangerously_run_function_in_subprocess(test_func)
assert returncode == 0
assert "configure_torch_cuda_allocator() must be called before importing torch." in stdout
@pytest.mark.skipif(not torch.cuda.is_available(), reason="Requires CUDA device.")
def test_configure_torch_cuda_allocator_warns_if_env_var_is_set_differently():
"""Test that configure_torch_cuda_allocator() logs at WARNING level if PYTORCH_CUDA_ALLOC_CONF is set and doesn't
match the requested configuration."""
def test_func():
import os
# Explicitly set the environment variable
os.environ["PYTORCH_CUDA_ALLOC_CONF"] = "backend:native"
from unittest.mock import MagicMock
from invokeai.app.util.torch_cuda_allocator import configure_torch_cuda_allocator
mock_logger = MagicMock()
# Set the PyTorch CUDA memory allocator a different configuration
configure_torch_cuda_allocator("backend:cudaMallocAsync", logger=mock_logger)
# Verify that the logger was called with the correct message
mock_logger.warning.assert_called_once()
args, _kwargs = mock_logger.warning.call_args
logged_message = args[0]
print(logged_message)
stdout, _stderr, returncode = dangerously_run_function_in_subprocess(test_func)
assert returncode == 0
assert "Attempted to configure the PyTorch CUDA memory allocator with 'backend:cudaMallocAsync'" in stdout
@pytest.mark.skipif(not torch.cuda.is_available(), reason="Requires CUDA device.")
def test_configure_torch_cuda_allocator_logs_if_env_var_is_already_set_correctly():
"""Test that configure_torch_cuda_allocator() logs at INFO level if PYTORCH_CUDA_ALLOC_CONF is set and matches the
requested configuration."""
def test_func():
import os
os.environ["PYTORCH_CUDA_ALLOC_CONF"] = "backend:native"
from unittest.mock import MagicMock
from invokeai.app.util.torch_cuda_allocator import configure_torch_cuda_allocator
mock_logger = MagicMock()
configure_torch_cuda_allocator("backend:native", logger=mock_logger)
mock_logger.info.assert_called_once()
args, _kwargs = mock_logger.info.call_args
logged_message = args[0]
print(logged_message)
stdout, _stderr, returncode = dangerously_run_function_in_subprocess(test_func)
assert returncode == 0
assert "PYTORCH_CUDA_ALLOC_CONF is already set to 'backend:native'" in stdout