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,672 @@
|
||||
"""Tests for multiuser workflow library functionality."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import status
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from invokeai.app.api.dependencies import ApiDependencies
|
||||
from invokeai.app.api_app import app
|
||||
from invokeai.app.services.config.config_default import InvokeAIAppConfig
|
||||
from invokeai.app.services.invocation_services import InvocationServices
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
from invokeai.app.services.users.users_common import UserCreateRequest
|
||||
from invokeai.app.services.workflow_records.workflow_records_sqlite import SqliteWorkflowRecordsStorage
|
||||
from invokeai.backend.util.logging import InvokeAILogger
|
||||
from tests.fixtures.sqlite_database import create_mock_sqlite_database
|
||||
|
||||
|
||||
class MockApiDependencies(ApiDependencies):
|
||||
invoker: Invoker
|
||||
|
||||
def __init__(self, invoker: Invoker) -> None:
|
||||
self.invoker = invoker
|
||||
|
||||
|
||||
WORKFLOW_BODY = {
|
||||
"name": "Test Workflow",
|
||||
"author": "",
|
||||
"description": "A test workflow",
|
||||
"version": "1.0.0",
|
||||
"contact": "",
|
||||
"tags": "",
|
||||
"notes": "",
|
||||
"nodes": [],
|
||||
"edges": [],
|
||||
"exposedFields": [],
|
||||
"meta": {"version": "3.0.0", "category": "user"},
|
||||
"id": None,
|
||||
"form_fields": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_jwt_secret():
|
||||
from invokeai.app.services.auth.token_service import set_jwt_secret
|
||||
|
||||
set_jwt_secret("test-secret-key-for-unit-tests-only-do-not-use-in-production")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_services() -> InvocationServices:
|
||||
from invokeai.app.services.board_image_records.board_image_records_sqlite import SqliteBoardImageRecordStorage
|
||||
from invokeai.app.services.board_records.board_records_sqlite import SqliteBoardRecordStorage
|
||||
from invokeai.app.services.boards.boards_default import BoardService
|
||||
from invokeai.app.services.bulk_download.bulk_download_default import BulkDownloadService
|
||||
from invokeai.app.services.client_state_persistence.client_state_persistence_sqlite import (
|
||||
ClientStatePersistenceSqlite,
|
||||
)
|
||||
from invokeai.app.services.image_records.image_records_sqlite import SqliteImageRecordStorage
|
||||
from invokeai.app.services.images.images_default import ImageService
|
||||
from invokeai.app.services.invocation_cache.invocation_cache_memory import MemoryInvocationCache
|
||||
from invokeai.app.services.invocation_stats.invocation_stats_default import InvocationStatsService
|
||||
from invokeai.app.services.users.users_default import UserService
|
||||
from tests.test_nodes import TestEventService
|
||||
|
||||
configuration = InvokeAIAppConfig(use_memory_db=True, node_cache_size=0)
|
||||
logger = InvokeAILogger.get_logger()
|
||||
db = create_mock_sqlite_database(configuration, logger)
|
||||
|
||||
return InvocationServices(
|
||||
board_image_records=SqliteBoardImageRecordStorage(db=db),
|
||||
board_images=None, # type: ignore
|
||||
board_records=SqliteBoardRecordStorage(db=db),
|
||||
boards=BoardService(),
|
||||
bulk_download=BulkDownloadService(),
|
||||
configuration=configuration,
|
||||
events=TestEventService(),
|
||||
image_files=None, # type: ignore
|
||||
image_records=SqliteImageRecordStorage(db=db),
|
||||
images=ImageService(),
|
||||
invocation_cache=MemoryInvocationCache(max_cache_size=0),
|
||||
logger=logging, # type: ignore
|
||||
model_images=None, # type: ignore
|
||||
model_manager=None, # type: ignore
|
||||
download_queue=None, # type: ignore
|
||||
names=None, # type: ignore
|
||||
performance_statistics=InvocationStatsService(),
|
||||
session_processor=None, # type: ignore
|
||||
session_queue=None, # type: ignore
|
||||
urls=None, # type: ignore
|
||||
workflow_records=SqliteWorkflowRecordsStorage(db=db),
|
||||
tensors=None, # type: ignore
|
||||
conditioning=None, # type: ignore
|
||||
style_preset_records=None, # type: ignore
|
||||
style_preset_image_files=None, # type: ignore
|
||||
workflow_thumbnails=None, # type: ignore
|
||||
model_relationship_records=None, # type: ignore
|
||||
model_relationships=None, # type: ignore
|
||||
client_state_persistence=ClientStatePersistenceSqlite(db=db),
|
||||
users=UserService(db),
|
||||
external_generation=None, # type: ignore
|
||||
)
|
||||
|
||||
|
||||
def create_test_user(mock_invoker: Invoker, email: str, display_name: str, is_admin: bool = False) -> str:
|
||||
user_service = mock_invoker.services.users
|
||||
user_data = UserCreateRequest(email=email, display_name=display_name, password="TestPass123", is_admin=is_admin)
|
||||
user = user_service.create(user_data)
|
||||
return user.user_id
|
||||
|
||||
|
||||
def get_user_token(client: TestClient, email: str) -> str:
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": email, "password": "TestPass123", "remember_me": False},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return response.json()["token"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def enable_multiuser(monkeypatch: Any, mock_invoker: Invoker):
|
||||
mock_invoker.services.configuration.multiuser = True
|
||||
mock_workflow_thumbnails = MagicMock()
|
||||
mock_workflow_thumbnails.get_url.return_value = None
|
||||
mock_invoker.services.workflow_thumbnails = mock_workflow_thumbnails
|
||||
|
||||
mock_deps = MockApiDependencies(mock_invoker)
|
||||
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", mock_deps)
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps)
|
||||
monkeypatch.setattr("invokeai.app.api.routers.workflows.ApiDependencies", mock_deps)
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_token(setup_jwt_secret: None, enable_multiuser: Any, mock_invoker: Invoker, client: TestClient):
|
||||
create_test_user(mock_invoker, "admin@test.com", "Admin", is_admin=True)
|
||||
return get_user_token(client, "admin@test.com")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user1_token(enable_multiuser: Any, mock_invoker: Invoker, client: TestClient, admin_token: str):
|
||||
create_test_user(mock_invoker, "user1@test.com", "User One", is_admin=False)
|
||||
return get_user_token(client, "user1@test.com")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user2_token(enable_multiuser: Any, mock_invoker: Invoker, client: TestClient, admin_token: str):
|
||||
create_test_user(mock_invoker, "user2@test.com", "User Two", is_admin=False)
|
||||
return get_user_token(client, "user2@test.com")
|
||||
|
||||
|
||||
def create_workflow(client: TestClient, token: str, workflow_body: dict[str, Any] | None = None) -> str:
|
||||
response = client.post(
|
||||
"/api/v1/workflows/",
|
||||
json={"workflow": workflow_body or WORKFLOW_BODY},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
return response.json()["workflow_id"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_list_workflows_requires_auth(enable_multiuser: Any, client: TestClient):
|
||||
response = client.get("/api/v1/workflows/")
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def test_create_workflow_requires_auth(enable_multiuser: Any, client: TestClient):
|
||||
response = client.post("/api/v1/workflows/", json={"workflow": WORKFLOW_BODY})
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ownership isolation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_workflows_are_isolated_between_users(client: TestClient, user1_token: str, user2_token: str):
|
||||
"""Users should only see their own workflows in list."""
|
||||
# user1 creates a workflow
|
||||
create_workflow(client, user1_token)
|
||||
|
||||
# user1 can see it
|
||||
r1 = client.get("/api/v1/workflows/?categories=user", headers={"Authorization": f"Bearer {user1_token}"})
|
||||
assert r1.status_code == 200
|
||||
assert r1.json()["total"] == 1
|
||||
|
||||
# user2 cannot see user1's workflow
|
||||
r2 = client.get("/api/v1/workflows/?categories=user", headers={"Authorization": f"Bearer {user2_token}"})
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["total"] == 0
|
||||
|
||||
|
||||
def test_user_cannot_delete_another_users_workflow(client: TestClient, user1_token: str, user2_token: str):
|
||||
workflow_id = create_workflow(client, user1_token)
|
||||
response = client.delete(
|
||||
f"/api/v1/workflows/i/{workflow_id}",
|
||||
headers={"Authorization": f"Bearer {user2_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_user_cannot_update_another_users_workflow(client: TestClient, user1_token: str, user2_token: str):
|
||||
workflow_id = create_workflow(client, user1_token)
|
||||
updated = {**WORKFLOW_BODY, "id": workflow_id, "name": "Hijacked"}
|
||||
response = client.patch(
|
||||
f"/api/v1/workflows/i/{workflow_id}",
|
||||
json={"workflow": updated},
|
||||
headers={"Authorization": f"Bearer {user2_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_owner_can_delete_own_workflow(client: TestClient, user1_token: str):
|
||||
workflow_id = create_workflow(client, user1_token)
|
||||
response = client.delete(
|
||||
f"/api/v1/workflows/i/{workflow_id}",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_admin_can_delete_any_workflow(client: TestClient, admin_token: str, user1_token: str):
|
||||
workflow_id = create_workflow(client, user1_token)
|
||||
response = client.delete(
|
||||
f"/api/v1/workflows/i/{workflow_id}",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_list_workflows_skips_stale_workflow_rows(
|
||||
client: TestClient, user1_token: str, mock_invoker: Invoker, monkeypatch: Any
|
||||
):
|
||||
workflow_id = create_workflow(client, user1_token)
|
||||
stale_id = "stale-workflow"
|
||||
workflow_records = mock_invoker.services.workflow_records
|
||||
existing = workflow_records.get(workflow_id)
|
||||
|
||||
original_get_many = workflow_records.get_many
|
||||
original_get = workflow_records.get
|
||||
|
||||
def fake_get_many(*args, **kwargs):
|
||||
results = original_get_many(*args, **kwargs)
|
||||
return results.model_copy(
|
||||
update={"items": [*results.items, results.items[0].model_copy(update={"workflow_id": stale_id})]}
|
||||
)
|
||||
|
||||
def fake_get(requested_workflow_id: str):
|
||||
if requested_workflow_id == stale_id:
|
||||
from invokeai.app.services.workflow_records.workflow_records_common import WorkflowNotFoundError
|
||||
|
||||
raise WorkflowNotFoundError("stale")
|
||||
return original_get(requested_workflow_id)
|
||||
|
||||
monkeypatch.setattr(workflow_records, "get_many", fake_get_many)
|
||||
monkeypatch.setattr(workflow_records, "get", fake_get)
|
||||
|
||||
response = client.get("/api/v1/workflows/?categories=user", headers={"Authorization": f"Bearer {user1_token}"})
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
payload = response.json()
|
||||
assert payload["total"] == 1
|
||||
assert [item["workflow_id"] for item in payload["items"]] == [existing.workflow_id]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared workflow (is_public)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_update_is_public_owner_succeeds(client: TestClient, user1_token: str):
|
||||
workflow_id = create_workflow(client, user1_token)
|
||||
response = client.patch(
|
||||
f"/api/v1/workflows/i/{workflow_id}/is_public",
|
||||
json={"is_public": True},
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["is_public"] is True
|
||||
|
||||
|
||||
def test_update_is_public_other_user_forbidden(client: TestClient, user1_token: str, user2_token: str):
|
||||
workflow_id = create_workflow(client, user1_token)
|
||||
response = client.patch(
|
||||
f"/api/v1/workflows/i/{workflow_id}/is_public",
|
||||
json={"is_public": True},
|
||||
headers={"Authorization": f"Bearer {user2_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_public_workflow_visible_to_other_users(client: TestClient, user1_token: str, user2_token: str):
|
||||
"""A shared (is_public=True) workflow should appear when filtering with is_public=true."""
|
||||
workflow_id = create_workflow(client, user1_token)
|
||||
# Make it public
|
||||
client.patch(
|
||||
f"/api/v1/workflows/i/{workflow_id}/is_public",
|
||||
json={"is_public": True},
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
|
||||
# user2 can see it through is_public=true filter
|
||||
response = client.get(
|
||||
"/api/v1/workflows/?categories=user&is_public=true",
|
||||
headers={"Authorization": f"Bearer {user2_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
ids = [w["workflow_id"] for w in response.json()["items"]]
|
||||
assert workflow_id in ids
|
||||
|
||||
|
||||
def test_private_workflow_not_visible_to_other_users(client: TestClient, user1_token: str, user2_token: str):
|
||||
"""A private (is_public=False) user workflow should NOT appear for another user."""
|
||||
workflow_id = create_workflow(client, user1_token)
|
||||
|
||||
# user2 lists 'yours' style (their own workflows)
|
||||
response = client.get(
|
||||
"/api/v1/workflows/?categories=user",
|
||||
headers={"Authorization": f"Bearer {user2_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
ids = [w["workflow_id"] for w in response.json()["items"]]
|
||||
assert workflow_id not in ids
|
||||
|
||||
|
||||
def test_public_workflow_still_in_owners_list(client: TestClient, user1_token: str):
|
||||
"""A shared workflow should still appear in the owner's own workflow list."""
|
||||
workflow_id = create_workflow(client, user1_token)
|
||||
client.patch(
|
||||
f"/api/v1/workflows/i/{workflow_id}/is_public",
|
||||
json={"is_public": True},
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
|
||||
# owner's 'yours' list (no is_public filter)
|
||||
response = client.get(
|
||||
"/api/v1/workflows/?categories=user",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
ids = [w["workflow_id"] for w in response.json()["items"]]
|
||||
assert workflow_id in ids
|
||||
|
||||
|
||||
def test_workflow_has_user_id_and_is_public_fields(client: TestClient, user1_token: str):
|
||||
"""Created workflow should return user_id and is_public fields."""
|
||||
response = client.post(
|
||||
"/api/v1/workflows/",
|
||||
json={"workflow": WORKFLOW_BODY},
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "user_id" in data
|
||||
assert "is_public" in data
|
||||
assert data["is_public"] is False
|
||||
|
||||
|
||||
def test_list_workflows_includes_call_saved_workflow_compatibility(client: TestClient, user1_token: str):
|
||||
compatible_workflow_id = create_workflow(
|
||||
client,
|
||||
user1_token,
|
||||
{
|
||||
**WORKFLOW_BODY,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "return",
|
||||
"type": "invocation",
|
||||
"position": {"x": 0, "y": 0},
|
||||
"data": {
|
||||
"id": "return",
|
||||
"type": "workflow_return",
|
||||
"version": "1.0.0",
|
||||
"nodePack": "invokeai",
|
||||
"label": "",
|
||||
"notes": "",
|
||||
"isOpen": True,
|
||||
"isIntermediate": False,
|
||||
"useCache": True,
|
||||
"dynamicInputTemplates": {},
|
||||
"inputs": {"collection": {"value": []}},
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
incompatible_workflow_id = create_workflow(client, user1_token)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/workflows/?categories=user",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
items_by_id = {item["workflow_id"]: item for item in response.json()["items"]}
|
||||
assert items_by_id[compatible_workflow_id]["call_saved_workflow_compatibility"] == {
|
||||
"is_callable": True,
|
||||
"reason": "ok",
|
||||
"message": None,
|
||||
}
|
||||
assert items_by_id[incompatible_workflow_id]["call_saved_workflow_compatibility"] == {
|
||||
"is_callable": False,
|
||||
"reason": "missing_workflow_return",
|
||||
"message": "The workflow must contain exactly one workflow_return node.",
|
||||
}
|
||||
|
||||
|
||||
def _create_callable_workflow(client: TestClient, token: str, name: str) -> str:
|
||||
return create_workflow(
|
||||
client,
|
||||
token,
|
||||
{
|
||||
**WORKFLOW_BODY,
|
||||
"name": name,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "return",
|
||||
"type": "invocation",
|
||||
"position": {"x": 0, "y": 0},
|
||||
"data": {
|
||||
"id": "return",
|
||||
"type": "workflow_return",
|
||||
"version": "1.0.0",
|
||||
"nodePack": "invokeai",
|
||||
"label": "",
|
||||
"notes": "",
|
||||
"isOpen": True,
|
||||
"isIntermediate": False,
|
||||
"useCache": True,
|
||||
"dynamicInputTemplates": {},
|
||||
"inputs": {"values": {"value": []}},
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_list_workflows_callable_filter_counts_only_callable_workflows(client: TestClient, user1_token: str):
|
||||
callable_a = _create_callable_workflow(client, user1_token, "Callable A")
|
||||
create_workflow(client, user1_token, {**WORKFLOW_BODY, "name": "Not Callable A"})
|
||||
callable_b = _create_callable_workflow(client, user1_token, "Callable B")
|
||||
create_workflow(client, user1_token, {**WORKFLOW_BODY, "name": "Not Callable B"})
|
||||
callable_c = _create_callable_workflow(client, user1_token, "Callable C")
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/workflows/?categories=user&callable=true&per_page=2&page=0&order_by=name&direction=ASC",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
payload = response.json()
|
||||
assert payload["total"] == 3
|
||||
assert payload["pages"] == 2
|
||||
assert payload["per_page"] == 2
|
||||
assert [item["workflow_id"] for item in payload["items"]] == [callable_a, callable_b]
|
||||
assert {item["workflow_id"] for item in payload["items"]}.isdisjoint({callable_c})
|
||||
assert all(item["call_saved_workflow_compatibility"]["is_callable"] for item in payload["items"])
|
||||
|
||||
|
||||
def test_list_workflows_callable_filter_paginates_callable_workflows_after_filtering(
|
||||
client: TestClient, user1_token: str
|
||||
):
|
||||
_create_callable_workflow(client, user1_token, "Callable A")
|
||||
create_workflow(client, user1_token, {**WORKFLOW_BODY, "name": "Not Callable A"})
|
||||
_create_callable_workflow(client, user1_token, "Callable B")
|
||||
create_workflow(client, user1_token, {**WORKFLOW_BODY, "name": "Not Callable B"})
|
||||
callable_c = _create_callable_workflow(client, user1_token, "Callable C")
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/workflows/?categories=user&callable=true&per_page=2&page=1&order_by=name&direction=ASC",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
payload = response.json()
|
||||
assert payload["total"] == 3
|
||||
assert payload["pages"] == 2
|
||||
assert payload["per_page"] == 2
|
||||
assert [item["workflow_id"] for item in payload["items"]] == [callable_c]
|
||||
assert all(item["call_saved_workflow_compatibility"]["is_callable"] for item in payload["items"])
|
||||
|
||||
|
||||
def test_get_workflow_includes_call_saved_workflow_compatibility(client: TestClient, user1_token: str):
|
||||
workflow_id = create_workflow(client, user1_token)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1/workflows/i/{workflow_id}",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["call_saved_workflow_compatibility"] == {
|
||||
"is_callable": False,
|
||||
"reason": "missing_workflow_return",
|
||||
"message": "The workflow must contain exactly one workflow_return node.",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System-owned workflow visibility (regression tests for migration 30 fix)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _insert_system_workflow(mock_invoker: Invoker, name: str = "Legacy Workflow", is_public: bool = True) -> str:
|
||||
"""Insert a workflow owned by 'system' directly via the service layer, then set is_public."""
|
||||
from invokeai.app.services.workflow_records.workflow_records_common import WorkflowWithoutID
|
||||
|
||||
wf = WorkflowWithoutID(**{**WORKFLOW_BODY, "name": name})
|
||||
record = mock_invoker.services.workflow_records.create(workflow=wf, user_id="system")
|
||||
if is_public:
|
||||
mock_invoker.services.workflow_records.update_is_public(workflow_id=record.workflow_id, is_public=True)
|
||||
return record.workflow_id
|
||||
|
||||
|
||||
def test_system_public_workflow_visible_in_shared_listing(client: TestClient, user1_token: str, mock_invoker: Invoker):
|
||||
"""After migration 30, system-owned public workflows should appear in the shared workflows listing."""
|
||||
wf_id = _insert_system_workflow(mock_invoker, "Legacy Workflow")
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/workflows/?categories=user&is_public=true",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
ids = [w["workflow_id"] for w in response.json()["items"]]
|
||||
assert wf_id in ids
|
||||
|
||||
|
||||
def test_system_public_workflow_not_in_your_workflows(client: TestClient, user1_token: str, mock_invoker: Invoker):
|
||||
"""System-owned public workflows should NOT appear in 'Your Workflows' listing."""
|
||||
wf_id = _insert_system_workflow(mock_invoker, "Legacy Workflow")
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/workflows/?categories=user",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
ids = [w["workflow_id"] for w in response.json()["items"]]
|
||||
assert wf_id not in ids
|
||||
|
||||
|
||||
def test_admin_can_list_system_workflows(client: TestClient, admin_token: str, mock_invoker: Invoker):
|
||||
"""Admins should see system-owned workflows in their listing."""
|
||||
wf_id = _insert_system_workflow(mock_invoker, "Admin Visible Workflow")
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/workflows/?categories=user",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
ids = [w["workflow_id"] for w in response.json()["items"]]
|
||||
assert wf_id in ids
|
||||
|
||||
|
||||
def test_admin_can_update_system_workflow(client: TestClient, admin_token: str, mock_invoker: Invoker):
|
||||
"""Admins should be able to update a system-owned workflow."""
|
||||
wf_id = _insert_system_workflow(mock_invoker, "Editable Legacy")
|
||||
|
||||
# Get the full workflow to update it
|
||||
get_resp = client.get(
|
||||
f"/api/v1/workflows/i/{wf_id}",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert get_resp.status_code == 200
|
||||
workflow_data = get_resp.json()["workflow"]
|
||||
workflow_data["name"] = "Updated by Admin"
|
||||
|
||||
update_resp = client.patch(
|
||||
f"/api/v1/workflows/i/{wf_id}",
|
||||
json={"workflow": workflow_data},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert update_resp.status_code == 200
|
||||
assert update_resp.json()["workflow"]["name"] == "Updated by Admin"
|
||||
|
||||
|
||||
def test_admin_can_delete_system_workflow(client: TestClient, admin_token: str, mock_invoker: Invoker):
|
||||
"""Admins should be able to delete a system-owned workflow."""
|
||||
wf_id = _insert_system_workflow(mock_invoker, "Deletable Legacy")
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1/workflows/i/{wf_id}",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_regular_user_cannot_update_system_workflow(client: TestClient, user1_token: str, mock_invoker: Invoker):
|
||||
"""Regular users should NOT be able to update a system-owned workflow."""
|
||||
wf_id = _insert_system_workflow(mock_invoker, "Protected Legacy")
|
||||
|
||||
get_resp = client.get(
|
||||
f"/api/v1/workflows/i/{wf_id}",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert get_resp.status_code == 200
|
||||
workflow_data = get_resp.json()["workflow"]
|
||||
workflow_data["name"] = "Hijacked"
|
||||
|
||||
update_resp = client.patch(
|
||||
f"/api/v1/workflows/i/{wf_id}",
|
||||
json={"workflow": workflow_data},
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert update_resp.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_regular_user_cannot_delete_system_workflow(client: TestClient, user1_token: str, mock_invoker: Invoker):
|
||||
"""Regular users should NOT be able to delete a system-owned workflow."""
|
||||
wf_id = _insert_system_workflow(mock_invoker, "Undeletable Legacy")
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1/workflows/i/{wf_id}",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Single-user mode: default ownership + sharing on create
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def single_user_mode(monkeypatch: Any, mock_invoker: Invoker):
|
||||
"""Configure the app for single-user (legacy) mode."""
|
||||
mock_invoker.services.configuration.multiuser = False
|
||||
mock_workflow_thumbnails = MagicMock()
|
||||
mock_workflow_thumbnails.get_url.return_value = None
|
||||
mock_invoker.services.workflow_thumbnails = mock_workflow_thumbnails
|
||||
|
||||
mock_deps = MockApiDependencies(mock_invoker)
|
||||
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", mock_deps)
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps)
|
||||
monkeypatch.setattr("invokeai.app.api.routers.workflows.ApiDependencies", mock_deps)
|
||||
yield
|
||||
|
||||
|
||||
def test_single_user_create_workflow_owned_by_system_and_public(single_user_mode: Any, client: TestClient):
|
||||
"""In single-user mode, newly created workflows should be owned by 'system' and shared (is_public=True)."""
|
||||
response = client.post("/api/v1/workflows/", json={"workflow": WORKFLOW_BODY})
|
||||
assert response.status_code == 200, response.text
|
||||
payload = response.json()
|
||||
assert payload["user_id"] == "system"
|
||||
assert payload["is_public"] is True
|
||||
|
||||
|
||||
def test_multiuser_create_workflow_owned_by_user_and_private(client: TestClient, user1_token: str):
|
||||
"""In multiuser mode, newly created workflows should be owned by the creator and private (is_public=False)."""
|
||||
response = client.post(
|
||||
"/api/v1/workflows/",
|
||||
json={"workflow": WORKFLOW_BODY},
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
payload = response.json()
|
||||
assert payload["user_id"] != "system"
|
||||
assert payload["is_public"] is False
|
||||
Reference in New Issue
Block a user