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
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:
@@ -0,0 +1 @@
|
||||
"""Tests for authentication services."""
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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],
|
||||
}
|
||||
+70
@@ -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
Reference in New Issue
Block a user