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
+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