InvokeAI/tests/app/routers/test_client_state_multiuser.py
Gohsuke Shimada 0a09452fa3
feat(ui): add canvas snapshot save/restore functionality (#8978)
* feat(ui): add canvas snapshot save/restore functionality

Add ability to save and restore canvas state snapshots, allowing users
to preserve their canvas layout at any point and restore it later.
This is useful when the canvas freezes or resets unexpectedly.

Backend:
- Add get_keys_by_prefix and delete_by_key to client_state persistence
- Add corresponding API endpoints

Frontend:
- Add canvasSnapshotRestored reducer to canvasSlice
- Add useCanvasSnapshots hook for snapshot CRUD operations
- Add CanvasToolbarSnapshotMenuButton with save/restore UI
- Add i18n keys for snapshot feature
- Regenerate API schema types

Tests:
- Add tests for new client_state endpoints (prefix search, key deletion)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(ui): address review feedback for canvas snapshot feature

- Preserve current modelBase on snapshot restore to prevent bbox desync
  with the active model (mirrors resetState pattern)
- Exclude snapshot restore from undo history so it cannot be accidentally
  undone
- Migrate manual fetch calls to RTKQ endpoints (clientState.ts) so
  snapshots go through the shared API transport layer with proper auth,
  session-expiry handling and sliding-window token refresh
- Validate referenced images on restore and warn when some are missing
- Detect incompatible (schema-changed) snapshots and show a specific
  error message instead of a generic failure toast
- Disable snapshot restore while the canvas is staging to prevent entity
  ID conflicts with in-progress generations
- Sort snapshot list by updated_at instead of rowid so re-saved
  snapshots appear at the top
- Add pre-flight backend reachability check before image validation to
  avoid false "missing images" warnings when offline

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(ui): consolidate collectImageNames to shared canvasProjectFile utility

Remove the local collectImageNames from useCanvasSnapshots and reuse
the shared, more comprehensive version from canvasProjectFile.ts that
was introduced by the canvas project save/load feature (#8917).

Snapshots don't include global ref images, so an empty array is passed
for that parameter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(canvas-snapshots): escape LIKE wildcards, warn on overwrite, fix default name chars

- Escape %, _, \ in client_state prefix query to prevent accidental wildcard matching
- Confirm before overwriting an existing snapshot instead of silently replacing it
- Use - instead of / and : in the default snapshot name to avoid key separator clashes

* fix(canvas): align canvasProjectRecalled with snapshot restore pattern

Preserve modelBase, call syncScaledSize, and exclude from undo history
to avoid bbox/model desync on project load — same pattern already used
by canvasSnapshotRestored.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Alexander Eichhorn <alex@eichhorn.dev>
2026-04-27 19:15:03 +00:00

445 lines
17 KiB
Python

"""Tests for multiuser client state functionality."""
from typing import Any
import pytest
from fastapi import status
from fastapi.testclient import TestClient
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.api_app import app
from invokeai.app.services.invoker import Invoker
from invokeai.app.services.users.users_common import UserCreateRequest
@pytest.fixture
def client():
"""Create a test client."""
return TestClient(app)
class MockApiDependencies(ApiDependencies):
"""Mock API dependencies for testing."""
invoker: Invoker
def __init__(self, invoker: Invoker) -> None:
self.invoker = invoker
def setup_test_user(
mock_invoker: Invoker, email: str, display_name: str, password: str = "TestPass123", is_admin: bool = False
) -> str:
"""Helper to create a test user and return user_id."""
user_service = mock_invoker.services.users
user_data = UserCreateRequest(
email=email,
display_name=display_name,
password=password,
is_admin=is_admin,
)
user = user_service.create(user_data)
return user.user_id
def get_user_token(client: TestClient, email: str, password: str = "TestPass123") -> str:
"""Helper to login and get a user token."""
response = client.post(
"/api/v1/auth/login",
json={
"email": email,
"password": password,
"remember_me": False,
},
)
assert response.status_code == 200
return response.json()["token"]
@pytest.fixture
def admin_token(monkeypatch: Any, mock_invoker: Invoker, client: TestClient):
"""Get an admin token for testing."""
# Enable multiuser mode for auth endpoints
mock_invoker.services.configuration.multiuser = True
# Mock ApiDependencies for auth and client_state routers
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
monkeypatch.setattr("invokeai.app.api.routers.client_state.ApiDependencies", MockApiDependencies(mock_invoker))
# Create admin user
setup_test_user(mock_invoker, "admin@test.com", "Admin User", is_admin=True)
return get_user_token(client, "admin@test.com")
@pytest.fixture
def user1_token(monkeypatch: Any, mock_invoker: Invoker, client: TestClient, admin_token: str):
"""Get a token for test user 1."""
# Create a regular user
setup_test_user(mock_invoker, "user1@test.com", "User One", is_admin=False)
return get_user_token(client, "user1@test.com")
@pytest.fixture
def user2_token(monkeypatch: Any, mock_invoker: Invoker, client: TestClient, admin_token: str):
"""Get a token for test user 2."""
# Create another regular user
setup_test_user(mock_invoker, "user2@test.com", "User Two", is_admin=False)
return get_user_token(client, "user2@test.com")
def test_get_client_state_without_auth_uses_system_user(client: TestClient, monkeypatch, mock_invoker: Invoker):
"""Test that getting client state without authentication uses the system user."""
# Mock ApiDependencies
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
monkeypatch.setattr("invokeai.app.api.routers.client_state.ApiDependencies", MockApiDependencies(mock_invoker))
# Set a value for the system user directly
mock_invoker.services.client_state_persistence.set_by_key("system", "test_key", "system_value")
# Get without authentication - should return system user's value
response = client.get("/api/v1/client_state/default/get_by_key?key=test_key")
assert response.status_code == status.HTTP_200_OK
assert response.json() == "system_value"
def test_set_client_state_without_auth_uses_system_user(client: TestClient, monkeypatch, mock_invoker: Invoker):
"""Test that setting client state without authentication uses the system user."""
# Mock ApiDependencies
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
monkeypatch.setattr("invokeai.app.api.routers.client_state.ApiDependencies", MockApiDependencies(mock_invoker))
# Set without authentication - should set for system user
response = client.post(
"/api/v1/client_state/default/set_by_key?key=test_key",
json="unauthenticated_value",
)
assert response.status_code == status.HTTP_200_OK
# Verify it was set for system user
value = mock_invoker.services.client_state_persistence.get_by_key("system", "test_key")
assert value == "unauthenticated_value"
def test_delete_client_state_without_auth_uses_system_user(client: TestClient, monkeypatch, mock_invoker: Invoker):
"""Test that deleting client state without authentication uses the system user."""
# Mock ApiDependencies
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
monkeypatch.setattr("invokeai.app.api.routers.client_state.ApiDependencies", MockApiDependencies(mock_invoker))
# Set a value for system user
mock_invoker.services.client_state_persistence.set_by_key("system", "test_key", "system_value")
# Delete without authentication - should delete system user's data
response = client.post("/api/v1/client_state/default/delete")
assert response.status_code == status.HTTP_200_OK
# Verify it was deleted for system user
value = mock_invoker.services.client_state_persistence.get_by_key("system", "test_key")
assert value is None
def test_set_and_get_client_state(client: TestClient, admin_token: str):
"""Test that authenticated users can set and get their client state."""
# Set a value
set_response = client.post(
"/api/v1/client_state/default/set_by_key?key=test_key",
json="test_value",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert set_response.status_code == status.HTTP_200_OK
assert set_response.json() == "test_value"
# Get the value back
get_response = client.get(
"/api/v1/client_state/default/get_by_key?key=test_key",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert get_response.status_code == status.HTTP_200_OK
assert get_response.json() == "test_value"
def test_client_state_isolation_between_users(client: TestClient, user1_token: str, user2_token: str):
"""Test that client state is isolated between different users."""
# User 1 sets a value
user1_set_response = client.post(
"/api/v1/client_state/default/set_by_key?key=shared_key",
json="user1_value",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert user1_set_response.status_code == status.HTTP_200_OK
# User 2 sets a different value for the same key
user2_set_response = client.post(
"/api/v1/client_state/default/set_by_key?key=shared_key",
json="user2_value",
headers={"Authorization": f"Bearer {user2_token}"},
)
assert user2_set_response.status_code == status.HTTP_200_OK
# User 1 should still see their own value
user1_get_response = client.get(
"/api/v1/client_state/default/get_by_key?key=shared_key",
headers={"Authorization": f"Bearer {user1_token}"},
)
assert user1_get_response.status_code == status.HTTP_200_OK
assert user1_get_response.json() == "user1_value"
# User 2 should see their own value
user2_get_response = client.get(
"/api/v1/client_state/default/get_by_key?key=shared_key",
headers={"Authorization": f"Bearer {user2_token}"},
)
assert user2_get_response.status_code == status.HTTP_200_OK
assert user2_get_response.json() == "user2_value"
def test_get_nonexistent_key_returns_null(client: TestClient, admin_token: str):
"""Test that getting a nonexistent key returns null."""
response = client.get(
"/api/v1/client_state/default/get_by_key?key=nonexistent_key",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == status.HTTP_200_OK
assert response.json() is None
def test_delete_client_state(client: TestClient, admin_token: str):
"""Test that users can delete their own client state."""
# Set some values
client.post(
"/api/v1/client_state/default/set_by_key?key=key1",
json="value1",
headers={"Authorization": f"Bearer {admin_token}"},
)
client.post(
"/api/v1/client_state/default/set_by_key?key=key2",
json="value2",
headers={"Authorization": f"Bearer {admin_token}"},
)
# Verify values exist
get_response = client.get(
"/api/v1/client_state/default/get_by_key?key=key1",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert get_response.json() == "value1"
# Delete all client state
delete_response = client.post(
"/api/v1/client_state/default/delete",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert delete_response.status_code == status.HTTP_200_OK
# Verify values are gone
get_response = client.get(
"/api/v1/client_state/default/get_by_key?key=key1",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert get_response.json() is None
get_response = client.get(
"/api/v1/client_state/default/get_by_key?key=key2",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert get_response.json() is None
def test_update_existing_key(client: TestClient, admin_token: str):
"""Test that updating an existing key works correctly."""
# Set initial value
client.post(
"/api/v1/client_state/default/set_by_key?key=update_key",
json="initial_value",
headers={"Authorization": f"Bearer {admin_token}"},
)
# Update the value
update_response = client.post(
"/api/v1/client_state/default/set_by_key?key=update_key",
json="updated_value",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert update_response.status_code == status.HTTP_200_OK
# Verify the updated value
get_response = client.get(
"/api/v1/client_state/default/get_by_key?key=update_key",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert get_response.status_code == status.HTTP_200_OK
assert get_response.json() == "updated_value"
def test_complex_json_values(client: TestClient, admin_token: str):
"""Test that complex JSON values can be stored and retrieved."""
import json
complex_dict = {"params": {"model": "test-model", "steps": 50}, "prompt": "a beautiful landscape"}
complex_value = json.dumps(complex_dict)
# Set complex value
set_response = client.post(
"/api/v1/client_state/default/set_by_key?key=complex_key",
json=complex_value,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert set_response.status_code == status.HTTP_200_OK
# Get it back
get_response = client.get(
"/api/v1/client_state/default/get_by_key?key=complex_key",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert get_response.status_code == status.HTTP_200_OK
assert get_response.json() == complex_value
def test_get_keys_by_prefix_without_auth(client: TestClient, monkeypatch, mock_invoker: Invoker):
"""Test that keys can be retrieved by prefix without authentication."""
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
monkeypatch.setattr("invokeai.app.api.routers.client_state.ApiDependencies", MockApiDependencies(mock_invoker))
# Set several keys with a common prefix directly
for i in range(3):
mock_invoker.services.client_state_persistence.set_by_key("system", f"canvas_snapshot:snap{i}", f"value{i}")
mock_invoker.services.client_state_persistence.set_by_key("system", "other_key", "other_value")
# Get keys by prefix
response = client.get("/api/v1/client_state/default/get_keys_by_prefix?prefix=canvas_snapshot:")
assert response.status_code == status.HTTP_200_OK
keys = response.json()
assert len(keys) == 3
assert "canvas_snapshot:snap0" in keys
assert "canvas_snapshot:snap1" in keys
assert "canvas_snapshot:snap2" in keys
assert "other_key" not in keys
def test_get_keys_by_prefix_empty_without_auth(client: TestClient, monkeypatch, mock_invoker: Invoker):
"""Test that an empty list is returned when no keys match the prefix."""
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
monkeypatch.setattr("invokeai.app.api.routers.client_state.ApiDependencies", MockApiDependencies(mock_invoker))
response = client.get("/api/v1/client_state/default/get_keys_by_prefix?prefix=nonexistent_prefix:")
assert response.status_code == status.HTTP_200_OK
assert response.json() == []
def test_delete_by_key_without_auth(client: TestClient, monkeypatch, mock_invoker: Invoker):
"""Test that a specific key can be deleted without affecting other keys."""
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
monkeypatch.setattr("invokeai.app.api.routers.client_state.ApiDependencies", MockApiDependencies(mock_invoker))
# Set two keys directly
mock_invoker.services.client_state_persistence.set_by_key("system", "keep_key", "keep_value")
mock_invoker.services.client_state_persistence.set_by_key("system", "delete_key", "delete_value")
# Delete only one key via endpoint
delete_response = client.post("/api/v1/client_state/default/delete_by_key?key=delete_key")
assert delete_response.status_code == status.HTTP_200_OK
# Verify deleted key is gone
value = mock_invoker.services.client_state_persistence.get_by_key("system", "delete_key")
assert value is None
# Verify other key still exists
value = mock_invoker.services.client_state_persistence.get_by_key("system", "keep_key")
assert value == "keep_value"
def test_get_keys_by_prefix(client: TestClient, admin_token: str):
"""Test that keys can be retrieved by prefix with authentication."""
# Set several keys with a common prefix
for i in range(3):
client.post(
f"/api/v1/client_state/default/set_by_key?key=canvas_snapshot:snap{i}",
json=f"value{i}",
headers={"Authorization": f"Bearer {admin_token}"},
)
# Set a key without the prefix
client.post(
"/api/v1/client_state/default/set_by_key?key=other_key",
json="other_value",
headers={"Authorization": f"Bearer {admin_token}"},
)
# Get keys by prefix
response = client.get(
"/api/v1/client_state/default/get_keys_by_prefix?prefix=canvas_snapshot:",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == status.HTTP_200_OK
keys = response.json()
assert len(keys) == 3
assert "canvas_snapshot:snap0" in keys
assert "canvas_snapshot:snap1" in keys
assert "canvas_snapshot:snap2" in keys
assert "other_key" not in keys
def test_delete_by_key(client: TestClient, admin_token: str):
"""Test that a specific key can be deleted without affecting other keys."""
# Set two keys
client.post(
"/api/v1/client_state/default/set_by_key?key=keep_key",
json="keep_value",
headers={"Authorization": f"Bearer {admin_token}"},
)
client.post(
"/api/v1/client_state/default/set_by_key?key=delete_key",
json="delete_value",
headers={"Authorization": f"Bearer {admin_token}"},
)
# Delete only one key
delete_response = client.post(
"/api/v1/client_state/default/delete_by_key?key=delete_key",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert delete_response.status_code == status.HTTP_200_OK
# Verify deleted key is gone
get_response = client.get(
"/api/v1/client_state/default/get_by_key?key=delete_key",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert get_response.json() is None
# Verify other key still exists
get_response = client.get(
"/api/v1/client_state/default/get_by_key?key=keep_key",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert get_response.json() == "keep_value"
def test_get_keys_by_prefix_isolation_between_users(client: TestClient, user1_token: str, user2_token: str):
"""Test that get_keys_by_prefix is isolated between users."""
# User 1 sets keys
client.post(
"/api/v1/client_state/default/set_by_key?key=snapshot:u1",
json="user1_data",
headers={"Authorization": f"Bearer {user1_token}"},
)
# User 2 sets keys
client.post(
"/api/v1/client_state/default/set_by_key?key=snapshot:u2",
json="user2_data",
headers={"Authorization": f"Bearer {user2_token}"},
)
# User 1 should only see their own keys
response = client.get(
"/api/v1/client_state/default/get_keys_by_prefix?prefix=snapshot:",
headers={"Authorization": f"Bearer {user1_token}"},
)
keys = response.json()
assert "snapshot:u1" in keys
assert "snapshot:u2" not in keys