From 5141eaa528425728ecc097186ad628f2583c97ae Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Mon, 27 Jul 2026 21:39:02 -0400 Subject: [PATCH 01/34] fix(api): report partial failures and bound batch bodies on image routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related gaps on the image endpoints, all of which the video endpoints already handle (they were fixed there during the #9163 review): 1. `star_images_in_list` / `unstar_images_in_list` re-raised the first HTTPException mid-batch, so one foreign name discarded the response payload for images that HAD been starred — the client never invalidated their caches and the UI showed them unstarred until the next full refresh. They now skip foreign/missing names like `delete_images_from_list` does, and dedup repeated names so one name can't land in two result buckets. 2. Those same handlers swallowed genuine storage failures with `except Exception: pass`, reporting a success-shaped response for images that were never updated. `StarredImagesResult` / `UnstarredImagesResult` gain `failed_images` (mirroring `DeleteImagesResult` and the video models), and the frontend toasts a partial-failure warning like the video star/unstar mutations do. 3. The `image_names` batch bodies (delete/star/unstar/images_by_names) were unbounded, and `list_image_dtos` had no pagination bounds — a negative LIMIT means *unlimited* in SQLite. Adds MAX_IMAGE_BATCH_SIZE (mirroring MAX_VIDEO_BATCH_SIZE), a 255-char per-name cap, and ge=0/le=MAX_PAGE_SIZE on the list route. The lower bound on `limit` is 0, not 1: the frontend issues count-only queries with limit=0. Deferred non-blocker from PR #9163. Co-Authored-By: Claude Opus 5 (1M context) --- invokeai/app/api/routers/images.py | 68 +++++++-- invokeai/app/services/images/images_common.py | 2 + invokeai/frontend/web/public/locales/en.json | 2 + .../web/src/services/api/endpoints/images.ts | 30 ++++ .../frontend/web/src/services/api/schema.ts | 10 ++ tests/app/routers/test_images.py | 135 ++++++++++++++++++ .../routers/test_multiuser_authorization.py | 14 +- 7 files changed, 245 insertions(+), 16 deletions(-) diff --git a/invokeai/app/api/routers/images.py b/invokeai/app/api/routers/images.py index b9e06befb9c..ee924545d8e 100644 --- a/invokeai/app/api/routers/images.py +++ b/invokeai/app/api/routers/images.py @@ -1,13 +1,13 @@ import io import json import traceback -from typing import ClassVar, Optional +from typing import Annotated, ClassVar, Optional from fastapi import BackgroundTasks, Body, HTTPException, Path, Query, Request, Response, UploadFile from fastapi.responses import FileResponse from fastapi.routing import APIRouter from PIL import Image -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, Field, StringConstraints, model_validator from invokeai.app.api.auth_dependencies import CurrentMediaUserOrDefault, CurrentUserOrDefault from invokeai.app.api.dependencies import ApiDependencies @@ -36,7 +36,7 @@ StarredImagesResult, UnstarredImagesResult, ) -from invokeai.app.services.shared.pagination import OffsetPaginatedResults +from invokeai.app.services.shared.pagination import MAX_PAGE_SIZE, OffsetPaginatedResults from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection from invokeai.app.util.controlnet_utils import heuristic_resize_fast from invokeai.backend.image_util.util import np_to_pil, pil_to_np @@ -47,6 +47,14 @@ # images are immutable; set a high max-age IMAGE_MAX_AGE = 31536000 +# Every name in a batch body costs at least one DB lookup, so an unbounded list lets an +# authenticated client pin a worker with a single request. Mirrors MAX_VIDEO_BATCH_SIZE +# in the videos router; comfortably above any selection the UI can produce. +MAX_IMAGE_BATCH_SIZE = 1000 +# Names are UUID-derived filenames; the bound only exists to keep a hostile body from +# turning into megabytes of SQL parameters. +ImageName = Annotated[str, StringConstraints(max_length=255)] + def _get_image_cache_control() -> str: if ApiDependencies.invoker.services.configuration.multiuser: @@ -455,8 +463,11 @@ async def list_image_dtos( default=None, description="The board id to filter by. Use 'none' to find images without a board.", ), - offset: int = Query(default=0, description="The page offset"), - limit: int = Query(default=10, description="The number of images per page"), + # Bounds matter: these flow verbatim into SQL, and a negative LIMIT means *unlimited* + # in SQLite — one request would materialize every image row into a DTO. The lower + # bound on `limit` is 0, not 1: the frontend issues limit=0 count-only queries. + offset: int = Query(default=0, ge=0, description="The page offset"), + limit: int = Query(default=10, ge=0, le=MAX_PAGE_SIZE, description="The number of images per page"), order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The order of sort"), starred_first: bool = Query(default=True, description="Whether to sort by starred images first"), search_term: Optional[str] = Query(default=None, description="The term to search for"), @@ -488,7 +499,9 @@ async def list_image_dtos( @images_router.post("/delete", operation_id="delete_images_from_list", response_model=DeleteImagesResult) async def delete_images_from_list( current_user: CurrentUserOrDefault, - image_names: list[str] = Body(description="The list of names of images to delete", embed=True), + image_names: list[ImageName] = Body( + description="The list of names of images to delete", embed=True, max_length=MAX_IMAGE_BATCH_SIZE + ), ) -> DeleteImagesResult: try: assert_image_move_maintenance_inactive() @@ -575,7 +588,9 @@ class ImagesUpdatedFromListResult(BaseModel): @images_router.post("/star", operation_id="star_images_in_list", response_model=StarredImagesResult) async def star_images_in_list( current_user: CurrentUserOrDefault, - image_names: list[str] = Body(description="The list of names of images to star", embed=True), + image_names: list[ImageName] = Body( + description="The list of names of images to star", embed=True, max_length=MAX_IMAGE_BATCH_SIZE + ), ) -> StarredImagesResult: try: assert_image_move_maintenance_inactive() @@ -585,9 +600,18 @@ async def star_images_in_list( raise try: + # Skip — but do not re-raise — auth failures so a foreign name mid-batch doesn't + # discard the response payload for images that were already starred. Re-raising + # turned partial successes into an error-shaped response, so the client never + # invalidated caches for the images that did change and the UI showed them + # unstarred until the next full refresh. Matches delete_images_from_list and the + # video star/unstar routes. starred_images: set[str] = set() + failed_images: set[str] = set() affected_boards: set[str] = set() - for image_name in image_names: + # Dedup while preserving order — a repeated name would otherwise be processed + # twice and could land in both starred_images and failed_images. + for image_name in dict.fromkeys(image_names): try: _assert_image_owner(image_name, current_user) updated_image_dto = ApiDependencies.invoker.services.images.update( @@ -596,11 +620,15 @@ async def star_images_in_list( starred_images.add(image_name) affected_boards.add(updated_image_dto.board_id or "none") except HTTPException: - raise + continue except Exception: - pass + # A genuine storage failure, not an auth/404 skip: it used to be swallowed + # by `pass`, so the client counted the image as starred and the star + # silently vanished on reload. + failed_images.add(image_name) return StarredImagesResult( starred_images=list(starred_images), + failed_images=list(failed_images), affected_boards=list(affected_boards), ) except HTTPException: @@ -612,7 +640,9 @@ async def star_images_in_list( @images_router.post("/unstar", operation_id="unstar_images_in_list", response_model=UnstarredImagesResult) async def unstar_images_in_list( current_user: CurrentUserOrDefault, - image_names: list[str] = Body(description="The list of names of images to unstar", embed=True), + image_names: list[ImageName] = Body( + description="The list of names of images to unstar", embed=True, max_length=MAX_IMAGE_BATCH_SIZE + ), ) -> UnstarredImagesResult: try: assert_image_move_maintenance_inactive() @@ -622,9 +652,12 @@ async def unstar_images_in_list( raise try: + # See star_images_in_list: skip foreign names instead of re-raising mid-batch, and + # report genuine storage failures instead of swallowing them. unstarred_images: set[str] = set() + failed_images: set[str] = set() affected_boards: set[str] = set() - for image_name in image_names: + for image_name in dict.fromkeys(image_names): try: _assert_image_owner(image_name, current_user) updated_image_dto = ApiDependencies.invoker.services.images.update( @@ -633,11 +666,12 @@ async def unstar_images_in_list( unstarred_images.add(image_name) affected_boards.add(updated_image_dto.board_id or "none") except HTTPException: - raise + continue except Exception: - pass + failed_images.add(image_name) return UnstarredImagesResult( unstarred_images=list(unstarred_images), + failed_images=list(failed_images), affected_boards=list(affected_boards), ) except HTTPException: @@ -784,7 +818,11 @@ async def get_image_names( ) async def get_images_by_names( current_user: CurrentUserOrDefault, - image_names: list[str] = Body(embed=True, description="Object containing list of image names to fetch DTOs for"), + image_names: list[ImageName] = Body( + embed=True, + description="Object containing list of image names to fetch DTOs for", + max_length=MAX_IMAGE_BATCH_SIZE, + ), ) -> list[ImageDTO]: """Gets image DTOs for the specified image names. Maintains order of input names.""" diff --git a/invokeai/app/services/images/images_common.py b/invokeai/app/services/images/images_common.py index 51679b43f4c..1acd42fac23 100644 --- a/invokeai/app/services/images/images_common.py +++ b/invokeai/app/services/images/images_common.py @@ -55,10 +55,12 @@ class DeleteImagesResult(ResultWithAffectedBoards): class StarredImagesResult(ResultWithAffectedBoards): starred_images: list[str] = Field(description="The names of the images that were starred") + failed_images: list[str] = Field(description="The names of images that were not starred") class UnstarredImagesResult(ResultWithAffectedBoards): unstarred_images: list[str] = Field(description="The names of the images that were unstarred") + failed_images: list[str] = Field(description="The names of images that were not unstarred") class AddImagesToBoardResult(ResultWithAffectedBoards): diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 3855d506db5..08c2338e80c 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1993,6 +1993,8 @@ "imageSavingFailed": "Image Saving Failed", "imageUploaded": "Image Uploaded", "imageUploadFailed": "Image Upload Failed", + "imagesFailedToUpdate": "{{count}} image could not be updated.", + "imagesFailedToUpdate_other": "{{count}} images could not be updated.", "videoUploaded": "Video Uploaded", "videoUploadFailed": "Video Upload Failed", "videoPlaybackFailed": "Unable to Play Video", diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index cd4ec8f39b3..26dee09641c 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -2,6 +2,8 @@ import { skipToken } from '@reduxjs/toolkit/query'; import { getStore } from 'app/store/nanostores/store'; import type { CroppableImageWithDims } from 'features/controlLayers/store/types'; import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types'; +import { toast } from 'features/toast/toast'; +import i18n from 'i18next'; import type { components, paths } from 'services/api/schema'; import type { GetImageNamesArgs, @@ -203,6 +205,20 @@ export const imagesApi = api.injectEndpoints({ method: 'POST', body, }), + async onQueryStarted(_, { queryFulfilled }) { + try { + const { data: result } = await queryFulfilled; + if (result.failed_images.length > 0) { + toast({ + id: 'IMAGES_FAILED_TO_UPDATE', + title: i18n.t('toast.imagesFailedToUpdate', { count: result.failed_images.length }), + status: 'warning', + }); + } + } catch { + // Global API error handling reports request-level failures. + } + }, invalidatesTags: (result) => { if (!result) { return []; @@ -228,6 +244,20 @@ export const imagesApi = api.injectEndpoints({ method: 'POST', body, }), + async onQueryStarted(_, { queryFulfilled }) { + try { + const { data: result } = await queryFulfilled; + if (result.failed_images.length > 0) { + toast({ + id: 'IMAGES_FAILED_TO_UPDATE', + title: i18n.t('toast.imagesFailedToUpdate', { count: result.failed_images.length }), + status: 'warning', + }); + } + } catch { + // Global API error handling reports request-level failures. + } + }, invalidatesTags: (result) => { if (!result) { return []; diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 3f2ef2a3a0a..5267158db9f 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -30778,6 +30778,11 @@ export type components = { * @description The names of the images that were starred */ starred_images: string[]; + /** + * Failed Images + * @description The names of images that were not starred + */ + failed_images: string[]; }; /** StarredVideosResult */ StarredVideosResult: { @@ -33135,6 +33140,11 @@ export type components = { * @description The names of the images that were unstarred */ unstarred_images: string[]; + /** + * Failed Images + * @description The names of images that were not unstarred + */ + failed_images: string[]; }; /** UnstarredVideosResult */ UnstarredVideosResult: { diff --git a/tests/app/routers/test_images.py b/tests/app/routers/test_images.py index 1e4270abff7..2f44b41f234 100644 --- a/tests/app/routers/test_images.py +++ b/tests/app/routers/test_images.py @@ -9,10 +9,13 @@ from invokeai.app.api.auth_dependencies import get_current_user_or_default from invokeai.app.api.dependencies import ApiDependencies +from invokeai.app.api.routers.images import MAX_IMAGE_BATCH_SIZE from invokeai.app.api_app import app from invokeai.app.services.auth.token_service import TokenData from invokeai.app.services.board_records.board_records_common import BoardRecord +from invokeai.app.services.images.images_common import ImageDTO from invokeai.app.services.invoker import Invoker +from invokeai.app.services.shared.pagination import MAX_PAGE_SIZE, OffsetPaginatedResults @pytest.fixture(autouse=True, scope="module") @@ -220,3 +223,135 @@ def test_get_bulk_download_image_image_deleted_after_response( client.get("/api/v1/images/download/test.zip") assert not (tmp_path / "test.zip").exists() + + +def prepare_image_batch_test(monkeypatch: Any, mock_invoker: Invoker) -> MagicMock: + """Wires the image router to a MagicMock image service with maintenance inactive. + + Returns the mock service so tests can script per-name update outcomes. + """ + images_service = MagicMock() + monkeypatch.setattr(mock_invoker.services, "images", images_service) + mock_invoker.services.image_moves = MagicMock() + mock_invoker.services.image_moves.is_maintenance_active.return_value = False + monkeypatch.setattr(mock_invoker.services.board_image_records, "get_board_for_image", MagicMock(return_value=None)) + + mock_deps = MockApiDependencies(mock_invoker) + monkeypatch.setattr("invokeai.app.api.routers.images.ApiDependencies", mock_deps) + monkeypatch.setattr("invokeai.app.api.routers._access.ApiDependencies", mock_deps) + monkeypatch.setattr("invokeai.app.api.routers.image_move_maintenance.ApiDependencies", mock_deps) + monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps) + return images_service + + +@pytest.fixture +def non_admin_user(): + """Makes ownership decisions depend on image_records.get_user_id rather than admin bypass.""" + + async def current_user_override() -> TokenData: + return TokenData(user_id="request-user", email="request-user@example.com", is_admin=False) + + app.dependency_overrides[get_current_user_or_default] = current_user_override + yield + app.dependency_overrides.pop(get_current_user_or_default, None) + + +@pytest.mark.parametrize( + ("route", "updated_key"), + [("star", "starred_images"), ("unstar", "unstarred_images")], +) +def test_star_unstar_reports_failures_and_keeps_partial_successes( + monkeypatch: Any, + mock_invoker: Invoker, + client: TestClient, + non_admin_user: None, + route: str, + updated_key: str, +) -> None: + """A foreign name is skipped, a storage failure is reported, and the rest still apply. + + Both used to abort the whole batch on the first foreign name (discarding the images + that HAD been updated) and to silently swallow storage failures, so the client cached + a star that never reached the DB. + """ + images_service = prepare_image_batch_test(monkeypatch, mock_invoker) + owners = {"ok.png": "request-user", "broken.png": "request-user", "foreign.png": "someone-else"} + monkeypatch.setattr( + mock_invoker.services.image_records, "get_user_id", MagicMock(side_effect=lambda name: owners.get(name)) + ) + + def update(image_name: str, changes: Any) -> MagicMock: + del changes + if image_name == "broken.png": + raise RuntimeError("storage is on fire") + dto = MagicMock() + dto.board_id = "board-1" + return dto + + images_service.update.side_effect = update + + response = client.post(f"/api/v1/images/{route}", json={"image_names": ["ok.png", "broken.png", "foreign.png"]}) + + assert response.status_code == 200 + body = response.json() + assert body[updated_key] == ["ok.png"] + # The genuine failure is reported; the foreign name is an intentional skip and must + # not be toasted as a failure. + assert body["failed_images"] == ["broken.png"] + assert body["affected_boards"] == ["board-1"] + + +@pytest.mark.parametrize("route", ["star", "unstar"]) +def test_star_unstar_dedupes_repeated_names( + monkeypatch: Any, mock_invoker: Invoker, client: TestClient, non_admin_user: None, route: str +) -> None: + images_service = prepare_image_batch_test(monkeypatch, mock_invoker) + monkeypatch.setattr(mock_invoker.services.image_records, "get_user_id", MagicMock(return_value="request-user")) + dto = MagicMock() + dto.board_id = "board-1" + images_service.update.return_value = dto + + response = client.post(f"/api/v1/images/{route}", json={"image_names": ["a.png", "a.png", "a.png"]}) + + assert response.status_code == 200 + assert images_service.update.call_count == 1 + + +@pytest.mark.parametrize("path", ["/api/v1/images/delete", "/api/v1/images/star", "/api/v1/images/unstar"]) +def test_image_name_batches_are_bounded(monkeypatch: Any, mock_invoker: Invoker, client: TestClient, path: str) -> None: + """An unbounded name list is a free amplification: each name costs a DB lookup.""" + prepare_image_batch_test(monkeypatch, mock_invoker) + response = client.post( + path, json={"image_names": [f"image-{index}.png" for index in range(MAX_IMAGE_BATCH_SIZE + 1)]} + ) + assert response.status_code == 422 + + response = client.post(path, json={"image_names": ["x" * 256]}) + assert response.status_code == 422 + + +@pytest.mark.parametrize( + "params", + [ + # A negative LIMIT means *unlimited* in SQLite — every image row, materialized. + {"limit": -1}, + {"limit": MAX_PAGE_SIZE + 1}, + {"offset": -1}, + ], +) +def test_list_image_dtos_rejects_out_of_range_pagination( + monkeypatch: Any, mock_invoker: Invoker, client: TestClient, params: dict[str, int] +) -> None: + prepare_image_batch_test(monkeypatch, mock_invoker) + assert client.get("/api/v1/images/", params=params).status_code == 422 + + +def test_list_image_dtos_allows_count_only_query(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None: + """The frontend issues limit=0 to read `total` without fetching rows.""" + images_service = prepare_image_batch_test(monkeypatch, mock_invoker) + images_service.get_many.return_value = OffsetPaginatedResults[ImageDTO](items=[], offset=0, limit=0, total=7) + + response = client.get("/api/v1/images/", params={"limit": 0}) + + assert response.status_code == 200 + assert response.json()["total"] == 7 diff --git a/tests/app/routers/test_multiuser_authorization.py b/tests/app/routers/test_multiuser_authorization.py index 1d8d3f5f52a..4e57834ee65 100644 --- a/tests/app/routers/test_multiuser_authorization.py +++ b/tests/app/routers/test_multiuser_authorization.py @@ -761,6 +761,13 @@ def test_non_owner_cannot_update_image( def test_non_owner_cannot_star_image( self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str ): + """Batch star skips foreign items instead of re-raising the first 403. + + Same rationale as test_non_owner_cannot_batch_delete_image: re-raising mid-batch + discarded the partial successes, so the client never learned which images HAD + changed. Only the response shape changes — the foreign image must still not be + starred, and must not be advertised as starred. + """ user1 = mock_invoker.services.users.get_by_email("user1@test.com") assert user1 is not None _save_image(mock_invoker, "user1-star-blocked", user1.user_id) @@ -770,7 +777,12 @@ def test_non_owner_cannot_star_image( json={"image_names": ["user1-star-blocked"]}, headers=_auth(user2_token), ) - assert r.status_code == status.HTTP_403_FORBIDDEN + assert r.status_code == status.HTTP_200_OK + body = r.json() + assert body["starred_images"] == [] + # An auth skip is not a failure — it must not be reported (and toasted) as one. + assert body["failed_images"] == [] + assert mock_invoker.services.image_records.get("user1-star-blocked").starred is False def test_non_owner_cannot_batch_delete_image( self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str From b8823e1d2b246e67e04ecfeff7b3b2c3773e816d Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Mon, 27 Jul 2026 22:09:00 -0400 Subject: [PATCH 02/34] chore: regenerate openapi.json for the new failed_images fields and batch bounds Co-Authored-By: Claude Opus 5 (1M context) --- invokeai/frontend/web/openapi.json | 39 +++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/invokeai/frontend/web/openapi.json b/invokeai/frontend/web/openapi.json index 6eb08766f19..2f9e0113ef7 100644 --- a/invokeai/frontend/web/openapi.json +++ b/invokeai/frontend/web/openapi.json @@ -4743,6 +4743,7 @@ "required": false, "schema": { "type": "integer", + "minimum": 0, "description": "The page offset", "default": 0, "title": "Offset" @@ -4755,6 +4756,8 @@ "required": false, "schema": { "type": "integer", + "maximum": 1000, + "minimum": 0, "description": "The number of images per page", "default": 10, "title": "Limit" @@ -15149,9 +15152,11 @@ "properties": { "image_names": { "items": { - "type": "string" + "type": "string", + "maxLength": 255 }, "type": "array", + "maxItems": 1000, "title": "Image Names", "description": "The list of names of images to delete" } @@ -15263,9 +15268,11 @@ "properties": { "image_names": { "items": { - "type": "string" + "type": "string", + "maxLength": 255 }, "type": "array", + "maxItems": 1000, "title": "Image Names", "description": "Object containing list of image names to fetch DTOs for" } @@ -15396,9 +15403,11 @@ "properties": { "image_names": { "items": { - "type": "string" + "type": "string", + "maxLength": 255 }, "type": "array", + "maxItems": 1000, "title": "Image Names", "description": "The list of names of images to star" } @@ -15411,9 +15420,11 @@ "properties": { "image_names": { "items": { - "type": "string" + "type": "string", + "maxLength": 255 }, "type": "array", + "maxItems": 1000, "title": "Image Names", "description": "The list of names of images to unstar" } @@ -71888,10 +71899,18 @@ "type": "array", "title": "Starred Images", "description": "The names of the images that were starred" + }, + "failed_images": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Failed Images", + "description": "The names of images that were not starred" } }, "type": "object", - "required": ["affected_boards", "starred_images"], + "required": ["affected_boards", "starred_images", "failed_images"], "title": "StarredImagesResult" }, "StarredVideosResult": { @@ -76226,10 +76245,18 @@ "type": "array", "title": "Unstarred Images", "description": "The names of the images that were unstarred" + }, + "failed_images": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Failed Images", + "description": "The names of images that were not unstarred" } }, "type": "object", - "required": ["affected_boards", "unstarred_images"], + "required": ["affected_boards", "unstarred_images", "failed_images"], "title": "UnstarredImagesResult" }, "UnstarredVideosResult": { From 32f4c2d43ca396a6ac7c0324a697e453674ba135 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sun, 16 Aug 2026 22:06:38 -0400 Subject: [PATCH 03/34] fix(api): bound the /images/download name list too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/download` was the one explicit-name batch route the bounds pass missed. It accepts `image_names`, authorizes every name individually, then schedules the bulk-download background task, so an authenticated client could still submit an oversized body and buy a per-name DB lookup each. Applies the same `ImageName` / `MAX_IMAGE_BATCH_SIZE` constraints the other four routes already use. Rejection is FastAPI request validation, so it lands before any authorization lookup or background task. Adds `/download` and `/images_by_names` to the existing bounds test, and a drift guard that walks the published OpenAPI schema and fails if any `/v1/images` request body takes an `image_names` array without both a list bound and a per-name length bound — the limits were applied route-by-route, which is how `/download` was missed in the first place. Scoped to the images router deliberately: `/v1/board_images/batch` and `/batch/delete` are unbounded too, but bounding them would reject a change-board request the UI can produce today, so they need to be paired with client-side chunking in a follow-up. Co-Authored-By: Claude Opus 5 (1M context) --- invokeai/app/api/routers/images.py | 7 ++- invokeai/frontend/web/openapi.json | 6 ++- tests/app/routers/test_images.py | 73 +++++++++++++++++++++++++++++- 3 files changed, 80 insertions(+), 6 deletions(-) diff --git a/invokeai/app/api/routers/images.py b/invokeai/app/api/routers/images.py index ee924545d8e..ef7516431ac 100644 --- a/invokeai/app/api/routers/images.py +++ b/invokeai/app/api/routers/images.py @@ -695,8 +695,11 @@ class ImagesDownloaded(BaseModel): async def download_images_from_list( current_user: CurrentUserOrDefault, background_tasks: BackgroundTasks, - image_names: Optional[list[str]] = Body( - default=None, description="The list of names of images to download", embed=True + image_names: Optional[list[ImageName]] = Body( + default=None, + description="The list of names of images to download", + embed=True, + max_length=MAX_IMAGE_BATCH_SIZE, ), board_id: Optional[str] = Body( default=None, description="The board from which image should be downloaded", embed=True diff --git a/invokeai/frontend/web/openapi.json b/invokeai/frontend/web/openapi.json index 98a0c11d4d6..56e29edd1b8 100644 --- a/invokeai/frontend/web/openapi.json +++ b/invokeai/frontend/web/openapi.json @@ -15830,9 +15830,11 @@ "anyOf": [ { "items": { - "type": "string" + "type": "string", + "maxLength": 255 }, - "type": "array" + "type": "array", + "maxItems": 1000 }, { "type": "null" diff --git a/tests/app/routers/test_images.py b/tests/app/routers/test_images.py index 2f44b41f234..c2bf3b139e7 100644 --- a/tests/app/routers/test_images.py +++ b/tests/app/routers/test_images.py @@ -317,10 +317,25 @@ def test_star_unstar_dedupes_repeated_names( assert images_service.update.call_count == 1 -@pytest.mark.parametrize("path", ["/api/v1/images/delete", "/api/v1/images/star", "/api/v1/images/unstar"]) +@pytest.mark.parametrize( + "path", + [ + "/api/v1/images/delete", + "/api/v1/images/star", + "/api/v1/images/unstar", + "/api/v1/images/images_by_names", + "/api/v1/images/download", + ], +) def test_image_name_batches_are_bounded(monkeypatch: Any, mock_invoker: Invoker, client: TestClient, path: str) -> None: """An unbounded name list is a free amplification: each name costs a DB lookup.""" - prepare_image_batch_test(monkeypatch, mock_invoker) + images_service = prepare_image_batch_test(monkeypatch, mock_invoker) + # /download would otherwise authorize every name and schedule a background task; if the + # bound ever regresses we want the assertion below to fail, not the service call to blow up. + bulk_download = MagicMock() + bulk_download.generate_item_id.return_value = "test" + monkeypatch.setattr(mock_invoker.services, "bulk_download", bulk_download) + response = client.post( path, json={"image_names": [f"image-{index}.png" for index in range(MAX_IMAGE_BATCH_SIZE + 1)]} ) @@ -329,6 +344,60 @@ def test_image_name_batches_are_bounded(monkeypatch: Any, mock_invoker: Invoker, response = client.post(path, json={"image_names": ["x" * 256]}) assert response.status_code == 422 + # Rejection is FastAPI request validation, so it happens before the route body runs: + # no per-name authorization lookups and no background task were scheduled. + assert images_service.get_dto.call_count == 0 + assert bulk_download.generate_item_id.call_count == 0 + + +def test_every_image_names_body_is_bounded(client: TestClient) -> None: + """Drift guard: a new explicit-name batch route must not ship without a bound. + + /download shipped unbounded because the limits were applied route-by-route rather + than to the shape. Rather than restate the limit on every route, assert the published + contract: every images-router request body that takes an `image_names` array declares + both a list bound and a per-name length bound. + + Scoped to /v1/images deliberately. The two /v1/board_images batch routes are unbounded + too, but bounding them would reject a >MAX_IMAGE_BATCH_SIZE change-board request that + the UI can produce today, so they are left for a follow-up that pairs the bound with + client-side chunking. + """ + schema = client.get("/openapi.json").json() + components = schema["components"]["schemas"] + + unbounded: list[str] = [] + checked = 0 + for path, operations in schema["paths"].items(): + if not path.startswith("/api/v1/images/"): + continue + for method, operation in operations.items(): + ref = ( + operation.get("requestBody", {}) + .get("content", {}) + .get("application/json", {}) + .get("schema", {}) + .get("$ref") + ) + if ref is None: + continue + body = components[ref.rsplit("/", 1)[-1]] + image_names = body.get("properties", {}).get("image_names") + if image_names is None: + continue + # Optional fields are wrapped in anyOf: [{array}, {null}]. + variants = image_names.get("anyOf", [image_names]) + array = next((variant for variant in variants if variant.get("type") == "array"), None) + if array is None: + continue + checked += 1 + if array.get("maxItems") is None or array.get("items", {}).get("maxLength") is None: + unbounded.append(f"{method.upper()} {path}") + + # Floor guards against the walk silently matching nothing if the schema shape changes. + assert checked >= 5 + assert unbounded == [], f"unbounded image_names batch bodies: {unbounded}" + @pytest.mark.parametrize( "params", From 3ddb72a5cf74b06733056409b6bbdcac56923bf0 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sun, 16 Aug 2026 22:41:34 -0400 Subject: [PATCH 04/34] fix(api,ui): bound every image_names batch body, chunk oversized ones client-side The bounds pass applied its limit route-by-route, which left two gaps. Backend: `/images/download` accepted `image_names` unbounded, authorized every name individually, then scheduled the bulk-download task. `/board_images/batch` and `/batch/delete` were unbounded too, and loop per name for permission checks. All three now carry the same `MAX_IMAGE_BATCH_SIZE` bound as the rest. That constant's comment understated the cost it guards. The authorization helpers short-circuit on the first hit, so an admin or a direct owner costs 0-1 queries per name -- but a user reading someone else's Shared/Public board falls through to `boards.get_dto()`, which is six queries including three COUNT aggregates over the board's contents. Since the routes are `async def` and the loop is synchronous, that work blocks the event loop. Hence one uniform bound, with no route granted a laxer one. Frontend: nothing capped a gallery *selection*. Select-all reads the whole board's name list, so one keystroke on a large board produced a selection an order of magnitude past the bound, and no batch call chunked. Delete was the worst case -- `handleDeletions` swallows the rejection, so an oversized delete silently did nothing. All seven batch calls now split oversized bodies into conforming requests. The five mutating ones merge the per-chunk results so callers and `invalidatesTags` still see one aggregate result; `images_by_names` concatenates (a plain ordered list, and its caller only upserts by name); `/download` cannot merge, so an oversized selection becomes several zips -- the socket handler already fetches per `bulk_download_complete` event, keyed on the event's item name. Chunks run sequentially: each is already up to 1000 names of DB work, and firing them concurrently would hand back exactly what the bound took away. A mid-run failure resolves to a partial success, not an error. The earlier chunks are already committed, and a bare error would discard their payload -- the very bug the partial-failure reporting on these routes exists to fix. It is not only the RTK cache at stake: `handleDeletions` drives the gallery selection and strips deleted images out of nodes, canvas layers and reference images off `deleted_images`, and none of that runs on a rejection. So the merged result is returned, the unreached names are toasted as failures, and only a run where nothing landed is an error. Tests: the bounds test covers all seven batch routes; a drift guard walks the published OpenAPI schema and fails if any `image_names` body ships without both a list bound and a per-name length bound, pinning the exact route set so a route the walk *skips* cannot pass unnoticed. Frontend unit tests cover chunk splitting, result merging, and both failure paths. Co-Authored-By: Claude Opus 5 (1M context) --- invokeai/app/api/routers/board_images.py | 9 +- invokeai/app/api/routers/images.py | 9 +- invokeai/frontend/web/openapi.json | 8 +- .../src/services/api/endpoints/images.test.ts | 140 ++++++++ .../web/src/services/api/endpoints/images.ts | 340 ++++++++++++------ tests/app/routers/test_images.py | 54 +-- 6 files changed, 426 insertions(+), 134 deletions(-) create mode 100644 invokeai/frontend/web/src/services/api/endpoints/images.test.ts diff --git a/invokeai/app/api/routers/board_images.py b/invokeai/app/api/routers/board_images.py index ea0273f02d6..8eeb8efb106 100644 --- a/invokeai/app/api/routers/board_images.py +++ b/invokeai/app/api/routers/board_images.py @@ -4,6 +4,7 @@ from invokeai.app.api.auth_dependencies import CurrentUserOrDefault from invokeai.app.api.dependencies import ApiDependencies from invokeai.app.api.routers.image_move_maintenance import assert_image_move_maintenance_inactive +from invokeai.app.api.routers.images import MAX_IMAGE_BATCH_SIZE, ImageName from invokeai.app.services.images.images_common import AddImagesToBoardResult, RemoveImagesFromBoardResult board_images_router = APIRouter(prefix="/v1/board_images", tags=["boards"]) @@ -132,7 +133,9 @@ async def remove_image_from_board( async def add_images_to_board( current_user: CurrentUserOrDefault, board_id: str = Body(description="The id of the board to add to"), - image_names: list[str] = Body(description="The names of the images to add", embed=True), + image_names: list[ImageName] = Body( + description="The names of the images to add", embed=True, max_length=MAX_IMAGE_BATCH_SIZE + ), ) -> AddImagesToBoardResult: """Adds a list of images to a board""" _assert_board_write_access(board_id, current_user) @@ -185,7 +188,9 @@ async def add_images_to_board( ) async def remove_images_from_board( current_user: CurrentUserOrDefault, - image_names: list[str] = Body(description="The names of the images to remove", embed=True), + image_names: list[ImageName] = Body( + description="The names of the images to remove", embed=True, max_length=MAX_IMAGE_BATCH_SIZE + ), ) -> RemoveImagesFromBoardResult: """Removes a list of images from their board, if they had one""" try: diff --git a/invokeai/app/api/routers/images.py b/invokeai/app/api/routers/images.py index ef7516431ac..d95cab4c5e6 100644 --- a/invokeai/app/api/routers/images.py +++ b/invokeai/app/api/routers/images.py @@ -49,7 +49,14 @@ # Every name in a batch body costs at least one DB lookup, so an unbounded list lets an # authenticated client pin a worker with a single request. Mirrors MAX_VIDEO_BATCH_SIZE -# in the videos router; comfortably above any selection the UI can produce. +# in the videos router. +# +# "At least one" is doing real work in that sentence. The authorization helpers in _access.py +# short-circuit on the first hit, so an admin or a direct owner costs 0-1 queries per name -- +# but a user reading someone else's Shared/Public board falls all the way through to +# boards.get_dto(), which is six queries including three COUNT aggregates over the board's +# contents. That is the case the bound has to hold, and it is why no route gets a laxer one: +# the oversized selections this cap rejects are split client-side instead. MAX_IMAGE_BATCH_SIZE = 1000 # Names are UUID-derived filenames; the bound only exists to keep a hostile body from # turning into megabytes of SQL parameters. diff --git a/invokeai/frontend/web/openapi.json b/invokeai/frontend/web/openapi.json index 56e29edd1b8..fabae19aea2 100644 --- a/invokeai/frontend/web/openapi.json +++ b/invokeai/frontend/web/openapi.json @@ -15667,9 +15667,11 @@ }, "image_names": { "items": { - "type": "string" + "type": "string", + "maxLength": 255 }, "type": "array", + "maxItems": 1000, "title": "Image Names", "description": "The names of the images to add" } @@ -15975,9 +15977,11 @@ "properties": { "image_names": { "items": { - "type": "string" + "type": "string", + "maxLength": 255 }, "type": "array", + "maxItems": 1000, "title": "Image Names", "description": "The names of the images to remove" } diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts new file mode 100644 index 00000000000..82bc5499f54 --- /dev/null +++ b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts @@ -0,0 +1,140 @@ +import { toast } from 'features/toast/toast'; +import i18n from 'i18next'; +import { buildChunkedImageBatchQueryFn, chunkImageNames, mergeImageBatchResults } from 'services/api/endpoints/images'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { api } from '..'; + +vi.mock('features/toast/toast', () => ({ toast: vi.fn() })); +vi.mock('i18next', () => ({ default: { t: vi.fn((key: string) => key) } })); + +/** Mirrors MAX_IMAGE_BATCH_SIZE in invokeai/app/api/routers/images.py. */ +const CHUNK_SIZE = 1000; + +const names = (count: number) => Array.from({ length: count }, (_, i) => `image-${i}.png`); + +describe('chunkImageNames', () => { + it('leaves a conforming list as a single request', () => { + expect(chunkImageNames(names(3))).toEqual([names(3)]); + expect(chunkImageNames(names(CHUNK_SIZE))).toHaveLength(1); + }); + + it('still issues one request for an empty list', () => { + // The routes answer an empty body with a well-formed empty result. Merging zero chunks + // would produce `{}`, and callers read fields like `failed_images` off the result. + expect(chunkImageNames([])).toEqual([[]]); + }); + + it('splits an oversized list into conforming chunks that cover it exactly', () => { + const all = names(2500); + const chunks = chunkImageNames(all); + expect(chunks.map((c) => c.length)).toEqual([1000, 1000, 500]); + expect(chunks.flat()).toEqual(all); + }); +}); + +describe('mergeImageBatchResults', () => { + it('unions each key and dedupes, so the caller sees one single-request-shaped result', () => { + expect( + mergeImageBatchResults([ + { deleted_images: ['a.png'], failed_images: [], affected_boards: ['board-1', 'none'] }, + { deleted_images: ['b.png'], failed_images: ['c.png'], affected_boards: ['board-1'] }, + ]) + ).toEqual({ + deleted_images: ['a.png', 'b.png'], + failed_images: ['c.png'], + affected_boards: ['board-1', 'none'], + }); + }); +}); + +describe('buildChunkedImageBatchQueryFn', () => { + type Arg = { image_names: string[]; board_id?: string }; + type Result = { added_images: string[]; affected_boards: string[] }; + type Request = { url: string; method: string; body: Arg }; + type Response = { data: Result } | { error: { status: number; data: string } }; + + const getTags = () => ['ImageCollectionCounts' as const]; + + beforeEach(() => { + vi.mocked(toast).mockClear(); + vi.mocked(i18n.t).mockClear(); + }); + + const run = (baseQuery: (args: Request) => Promise, arg: Arg) => { + const dispatch = vi.fn(); + const queryFn = buildChunkedImageBatchQueryFn( + () => ({ url: '/api/v1/board_images/batch', method: 'POST' }), + getTags + ); + /* eslint-disable @typescript-eslint/no-explicit-any */ + return { dispatch, result: queryFn(arg, { dispatch } as any, undefined, baseQuery as any) }; + /* eslint-enable @typescript-eslint/no-explicit-any */ + }; + + it('sends one request per chunk, carrying the non-name body fields on each', async () => { + const baseQuery = vi.fn( + (_args: Request): Promise => Promise.resolve({ data: { added_images: [], affected_boards: [] } }) + ); + + const { result } = run(baseQuery, { image_names: names(2500), board_id: 'board-1' }); + await result; + + expect(baseQuery).toHaveBeenCalledTimes(3); + // board_id must ride along with every chunk, not just the first. + expect(baseQuery.mock.calls.map(([args]) => args.body.board_id)).toEqual(['board-1', 'board-1', 'board-1']); + expect(baseQuery.mock.calls.map(([args]) => args.body.image_names.length)).toEqual([1000, 1000, 500]); + }); + + it('merges the per-chunk results into one aggregate', async () => { + let call = 0; + const baseQuery = vi.fn((_args: Request): Promise => { + call += 1; + return Promise.resolve({ data: { added_images: [`chunk-${call}.png`], affected_boards: ['board-1'] } }); + }); + + const { result } = run(baseQuery, { image_names: names(1500) }); + + expect(await result).toEqual({ + data: { added_images: ['chunk-1.png', 'chunk-2.png'], affected_boards: ['board-1'] }, + }); + }); + + it('reports a mid-run failure as a partial success, keeping what the server already applied', async () => { + // A bare error would discard the first chunk's payload. It is not only the RTK cache at + // stake: handleDeletions drives the gallery selection and strips deleted images out of + // nodes, canvas layers and reference images off `deleted_images`, and none of that runs on + // a rejection — so 1000 images would be gone from the DB and still referenced by the canvas. + let call = 0; + const baseQuery = vi.fn((_args: Request): Promise => { + call += 1; + if (call === 3) { + return Promise.resolve({ error: { status: 500, data: 'boom' } }); + } + return Promise.resolve({ data: { added_images: [`chunk-${call}.png`], affected_boards: ['board-1'] } }); + }); + + const { dispatch, result } = run(baseQuery, { image_names: names(2500) }); + + expect(await result).toEqual({ + data: { added_images: ['chunk-1.png', 'chunk-2.png'], affected_boards: ['board-1'] }, + }); + expect(baseQuery).toHaveBeenCalledTimes(3); // stopped, did not keep firing chunks + expect(dispatch).toHaveBeenCalledWith(api.util.invalidateTags(getTags())); + // The failing chunk's 500 names are unreached, not merely un-reported. + expect(toast).toHaveBeenCalledWith(expect.objectContaining({ id: 'IMAGES_FAILED_TO_UPDATE', status: 'warning' })); + expect(i18n.t).toHaveBeenCalledWith('toast.imagesFailedToUpdate', { count: 500 }); + }); + + it('reports an error when the first chunk fails, since nothing was applied', async () => { + const baseQuery = vi.fn( + (_args: Request): Promise => Promise.resolve({ error: { status: 403, data: 'nope' } }) + ); + + const { dispatch, result } = run(baseQuery, { image_names: names(1500) }); + + expect(await result).toEqual({ error: { status: 403, data: 'nope' } }); + expect(dispatch).not.toHaveBeenCalled(); + expect(toast).not.toHaveBeenCalled(); + }); +}); diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index 26dee09641c..52c96c89b04 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -1,5 +1,7 @@ +import type { FetchArgs, FetchBaseQueryError, QueryReturnValue } from '@reduxjs/toolkit/query'; import { skipToken } from '@reduxjs/toolkit/query'; import { getStore } from 'app/store/nanostores/store'; +import { uniq } from 'es-toolkit'; import type { CroppableImageWithDims } from 'features/controlLayers/store/types'; import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types'; import { toast } from 'features/toast/toast'; @@ -46,6 +48,160 @@ const buildImagesUrl = (path: string = '', query?: Parameters */ const buildBoardImagesUrl = (path: string = '') => buildV1Url(`board_images/${path}`); +/** + * The batch routes cap `image_names` server-side (`MAX_IMAGE_BATCH_SIZE` in + * `invokeai/app/api/routers/images.py`) so one authenticated client cannot pin a worker with a + * single request. Nothing caps a gallery *selection*: select-all reads the whole board's name + * list, so one keystroke on a large board produces a selection an order of magnitude past the + * cap. Oversized bodies are therefore split client-side into conforming requests. + * + * Must stay <= the backend constant. + */ +const IMAGE_BATCH_CHUNK_SIZE = 1000; + +/** Every batch route answers with an object whose values are all name lists. */ +type ImageBatchResult = Record; + +type InvalidateTagsArg = Parameters[0]; + +/** The `baseQuery` handed to a `queryFn`, matching what `fetchBaseQuery` produces. */ +type ImagesBaseQuery = ( + args: string | FetchArgs +) => QueryReturnValue | PromiseLike>; + +export const chunkImageNames = (image_names: string[]): string[][] => { + if (image_names.length <= IMAGE_BATCH_CHUNK_SIZE) { + // Single-request path, byte-for-byte what it was before chunking existed. Note this also + // covers the empty list: the routes answer it with a well-formed empty result, which + // merging zero chunks could not reproduce. + return [image_names]; + } + const chunks: string[][] = []; + for (let i = 0; i < image_names.length; i += IMAGE_BATCH_CHUNK_SIZE) { + chunks.push(image_names.slice(i, i + IMAGE_BATCH_CHUNK_SIZE)); + } + return chunks; +}; + +/** + * Unions the per-chunk results key-by-key, so callers and `invalidatesTags` see one aggregate + * result with the same shape a single request would have returned. Keys are unioned rather + * than enumerated because the five batch routes name their outcome list differently + * (`deleted_images`, `starred_images`, `added_images`, ...) while sharing `affected_boards`. + */ +export const mergeImageBatchResults = (results: TResult[]): TResult => { + const merged: ImageBatchResult = {}; + for (const result of results) { + for (const [key, names] of Object.entries(result)) { + merged[key] = merged[key] ? uniq(merged[key].concat(names)) : names; + } + } + return merged as TResult; +}; + +/** + * Builds a `queryFn` that runs a batch mutation one conforming chunk at a time. + * + * Chunks go sequentially on purpose. Each is already up to `IMAGE_BATCH_CHUNK_SIZE` rows of DB + * work, and the server-side bound exists to stop a single client from pinning a worker — firing + * the chunks concurrently would hand straight back what the bound took away. + * + * The mid-run failure is the case that matters, and it resolves to a partial success rather than + * an error. Earlier chunks have already been committed by the server; returning a bare error + * would discard their payload, which is exactly the bug the partial-failure reporting on these + * routes exists to fix. It is not only the RTK cache at stake — `handleDeletions` drives the + * gallery selection and strips deleted images out of nodes, canvas layers and reference images + * off `result.deleted_images`, and none of that runs on a rejection. So the merged result is + * returned, the unreached names are toasted as failures, and only a run where *nothing* landed + * surfaces as an error. + */ +export const buildChunkedImageBatchQueryFn = + ( + request: (body: TArg) => { url: string; method: string }, + getTags: (result: TResult) => InvalidateTagsArg + ) => + async ( + arg: TArg, + { dispatch }: { dispatch: (action: ReturnType) => unknown }, + _extraOptions: unknown, + baseQuery: ImagesBaseQuery + ) => { + const results: TResult[] = []; + const chunks = chunkImageNames(arg.image_names); + for (const [index, image_names] of chunks.entries()) { + const response = await baseQuery({ ...request(arg), body: { ...arg, image_names } }); + if (response.error) { + if (results.length === 0) { + // Nothing was applied, so this is an ordinary failed request — report it as one. + return { error: response.error }; + } + // Everything from this chunk on is unreached, not merely un-reported. + const unreached = chunks.slice(index).flat(); + toastFailedImages(unreached.length); + dispatch(api.util.invalidateTags(getTags(mergeImageBatchResults(results)))); + return { data: mergeImageBatchResults(results) }; + } + results.push(response.data as TResult); + } + return { data: mergeImageBatchResults(results) }; + }; + +/** + * Tag sets for the chunked batch mutations. Extracted from the endpoints so the chunked + * `queryFn` can invalidate for the chunks that landed before a mid-run failure using the exact + * tag set the endpoint publishes on success, rather than a hand-rolled approximation. + */ +const getDeleteImagesTags = (result: components['schemas']['DeleteImagesResult']): InvalidateTagsArg => [ + // We ignore the deleted images when getting tags to invalidate. If we did not, we will invalidate the queries + // that fetch image DTOs, metadata, and workflows. But we have just deleted those images! Invalidating the tags + // will force those queries to re-fetch, and the requests will of course 404. + ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards), + 'ImageCollectionCounts', + { type: 'ImageCollection', id: LIST_TAG }, +]; + +const getStarImagesTags = (result: components['schemas']['StarredImagesResult']): InvalidateTagsArg => [ + ...getTagsToInvalidateForImageMutation(result.starred_images), + ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards), + 'ImageCollectionCounts', + { type: 'ImageCollection', id: 'starred' }, + { type: 'ImageCollection', id: 'unstarred' }, +]; + +const getUnstarImagesTags = (result: components['schemas']['UnstarredImagesResult']): InvalidateTagsArg => [ + ...getTagsToInvalidateForImageMutation(result.unstarred_images), + ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards), + 'ImageCollectionCounts', + { type: 'ImageCollection', id: 'starred' }, + { type: 'ImageCollection', id: 'unstarred' }, +]; + +const getAddImagesToBoardTags = (result: components['schemas']['AddImagesToBoardResult']): InvalidateTagsArg => [ + ...getTagsToInvalidateForImageMutation(result.added_images), + ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards), +]; + +const getRemoveImagesFromBoardTags = ( + result: components['schemas']['RemoveImagesFromBoardResult'] +): InvalidateTagsArg => [ + ...getTagsToInvalidateForImageMutation(result.removed_images), + ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards), +]; + +/** + * Surfaces the partial-failure warning. Used for both kinds of partial failure: the names the + * server reported it could not apply, and the names a chunked run never reached. + */ +const toastFailedImages = (count: number) => { + if (count > 0) { + toast({ + id: 'IMAGES_FAILED_TO_UPDATE', + title: i18n.t('toast.imagesFailedToUpdate', { count }), + status: 'warning', + }); + } +}; + export const imagesApi = api.injectEndpoints({ endpoints: (build) => ({ /** @@ -133,24 +289,11 @@ export const imagesApi = api.injectEndpoints({ paths['/api/v1/images/delete']['post']['responses']['200']['content']['application/json'], paths['/api/v1/images/delete']['post']['requestBody']['content']['application/json'] >({ - query: (body) => ({ - url: buildImagesUrl('delete'), - method: 'POST', - body, - }), - invalidatesTags: (result) => { - if (!result) { - return []; - } - // We ignore the deleted images when getting tags to invalidate. If we did not, we will invalidate the queries - // that fetch image DTOs, metadata, and workflows. But we have just deleted those images! Invalidating the tags - // will force those queries to re-fetch, and the requests will of course 404. - return [ - ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards), - 'ImageCollectionCounts', - { type: 'ImageCollection', id: LIST_TAG }, - ]; - }, + queryFn: buildChunkedImageBatchQueryFn( + () => ({ url: buildImagesUrl('delete'), method: 'POST' }), + getDeleteImagesTags + ), + invalidatesTags: (result) => (result ? getDeleteImagesTags(result) : []), }), deleteUncategorizedImages: build.mutation< paths['/api/v1/images/uncategorized']['delete']['responses']['200']['content']['application/json'], @@ -200,37 +343,19 @@ export const imagesApi = api.injectEndpoints({ paths['/api/v1/images/star']['post']['responses']['200']['content']['application/json'], paths['/api/v1/images/star']['post']['requestBody']['content']['application/json'] >({ - query: (body) => ({ - url: buildImagesUrl('star'), - method: 'POST', - body, - }), + queryFn: buildChunkedImageBatchQueryFn( + () => ({ url: buildImagesUrl('star'), method: 'POST' }), + getStarImagesTags + ), async onQueryStarted(_, { queryFulfilled }) { try { const { data: result } = await queryFulfilled; - if (result.failed_images.length > 0) { - toast({ - id: 'IMAGES_FAILED_TO_UPDATE', - title: i18n.t('toast.imagesFailedToUpdate', { count: result.failed_images.length }), - status: 'warning', - }); - } + toastFailedImages(result.failed_images.length); } catch { // Global API error handling reports request-level failures. } }, - invalidatesTags: (result) => { - if (!result) { - return []; - } - return [ - ...getTagsToInvalidateForImageMutation(result.starred_images), - ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards), - 'ImageCollectionCounts', - { type: 'ImageCollection', id: 'starred' }, - { type: 'ImageCollection', id: 'unstarred' }, - ]; - }, + invalidatesTags: (result) => (result ? getStarImagesTags(result) : []), }), /** * Unstar a list of images. @@ -239,37 +364,19 @@ export const imagesApi = api.injectEndpoints({ paths['/api/v1/images/unstar']['post']['responses']['200']['content']['application/json'], paths['/api/v1/images/unstar']['post']['requestBody']['content']['application/json'] >({ - query: (body) => ({ - url: buildImagesUrl('unstar'), - method: 'POST', - body, - }), + queryFn: buildChunkedImageBatchQueryFn( + () => ({ url: buildImagesUrl('unstar'), method: 'POST' }), + getUnstarImagesTags + ), async onQueryStarted(_, { queryFulfilled }) { try { const { data: result } = await queryFulfilled; - if (result.failed_images.length > 0) { - toast({ - id: 'IMAGES_FAILED_TO_UPDATE', - title: i18n.t('toast.imagesFailedToUpdate', { count: result.failed_images.length }), - status: 'warning', - }); - } + toastFailedImages(result.failed_images.length); } catch { // Global API error handling reports request-level failures. } }, - invalidatesTags: (result) => { - if (!result) { - return []; - } - return [ - ...getTagsToInvalidateForImageMutation(result.unstarred_images), - ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards), - 'ImageCollectionCounts', - { type: 'ImageCollection', id: 'starred' }, - { type: 'ImageCollection', id: 'unstarred' }, - ]; - }, + invalidatesTags: (result) => (result ? getUnstarImagesTags(result) : []), }), uploadImage: build.mutation< paths['/api/v1/images/upload']['post']['responses']['201']['content']['application/json'], @@ -429,52 +536,54 @@ export const imagesApi = api.injectEndpoints({ paths['/api/v1/board_images/batch']['post']['responses']['201']['content']['application/json'], paths['/api/v1/board_images/batch']['post']['requestBody']['content']['application/json'] >({ - query: (body) => ({ - url: buildBoardImagesUrl('batch'), - method: 'POST', - body, - }), - invalidatesTags: (result) => { - if (!result) { - return []; - } - return [ - ...getTagsToInvalidateForImageMutation(result.added_images), - ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards), - ]; - }, + queryFn: buildChunkedImageBatchQueryFn( + () => ({ url: buildBoardImagesUrl('batch'), method: 'POST' }), + getAddImagesToBoardTags + ), + invalidatesTags: (result) => (result ? getAddImagesToBoardTags(result) : []), }), removeImagesFromBoard: build.mutation< paths['/api/v1/board_images/batch/delete']['post']['responses']['201']['content']['application/json'], paths['/api/v1/board_images/batch/delete']['post']['requestBody']['content']['application/json'] >({ - query: (body) => ({ - url: buildBoardImagesUrl('batch/delete'), - method: 'POST', - body, - }), - invalidatesTags: (result) => { - if (!result) { - return []; - } - return [ - ...getTagsToInvalidateForImageMutation(result.removed_images), - ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards), - ]; - }, + queryFn: buildChunkedImageBatchQueryFn( + () => ({ url: buildBoardImagesUrl('batch/delete'), method: 'POST' }), + getRemoveImagesFromBoardTags + ), + invalidatesTags: (result) => (result ? getRemoveImagesFromBoardTags(result) : []), }), bulkDownloadImages: build.mutation< components['schemas']['ImagesDownloaded'], components['schemas']['Body_download_images_from_list'] >({ - query: ({ image_names, board_id }) => ({ - url: buildImagesUrl('download'), - method: 'POST', - body: { - image_names, - board_id, - }, - }), + /** + * Chunked like the mutating batch routes, but it cannot merge: each request produces its + * own bulk-download item, so an oversized selection becomes several zips rather than one. + * That is the cost of keeping the selection downloadable at all — the alternative is a 422 + * the moment the user hits select-all on a board past the cap. + * + * Only the first item name is returned, and only to give `matchFulfilled` a name for the + * single "preparing" toast. The zips themselves arrive independently: each background task + * emits its own `bulk_download_complete`, and the socket handler fetches and saves per + * event, keyed on the item name in the event rather than on this payload. + */ + queryFn: async ({ image_names, board_id }, _api, _extraOptions, baseQuery) => { + // A board download expands server-side from board_id alone, so there is nothing to split. + const chunks = image_names?.length ? chunkImageNames(image_names) : [image_names ?? []]; + let first: components['schemas']['ImagesDownloaded'] | undefined; + for (const chunk of chunks) { + const response = await baseQuery({ + url: buildImagesUrl('download'), + method: 'POST', + body: { image_names: chunk, board_id }, + }); + if (response.error) { + return { error: response.error }; + } + first ??= response.data as components['schemas']['ImagesDownloaded']; + } + return { data: first as components['schemas']['ImagesDownloaded'] }; + }, }), /** * Get ordered list of image names for selection operations @@ -497,11 +606,28 @@ export const imagesApi = api.injectEndpoints({ paths['/api/v1/images/images_by_names']['post']['responses']['200']['content']['application/json'], paths['/api/v1/images/images_by_names']['post']['requestBody']['content']['application/json'] >({ - query: (body) => ({ - url: buildImagesUrl('images_by_names'), - method: 'POST', - body, - }), + /** + * Chunked too, despite being a read: `useRangeBasedImageFetching` unions every virtuoso + * range seen inside its throttle window, and a dense grid with a 4096px overscan can carry + * that past the cap. The failure was silent — the hook swallows the rejection and nothing + * listens for it, so the affected thumbnails simply never loaded. Results concatenate + * because the route returns a plain ordered list, and the caller only upserts by name. + */ + queryFn: async ({ image_names }, _api, _extraOptions, baseQuery) => { + const imageDTOs: ImageDTO[] = []; + for (const chunk of chunkImageNames(image_names)) { + const response = await baseQuery({ + url: buildImagesUrl('images_by_names'), + method: 'POST', + body: { image_names: chunk }, + }); + if (response.error) { + return { error: response.error }; + } + imageDTOs.push(...(response.data as ImageDTO[])); + } + return { data: imageDTOs }; + }, // Don't provide cache tags - we'll manually upsert into individual getImageDTO caches async onQueryStarted(_, { dispatch, queryFulfilled }) { try { diff --git a/tests/app/routers/test_images.py b/tests/app/routers/test_images.py index c2bf3b139e7..8a9f3b32aa9 100644 --- a/tests/app/routers/test_images.py +++ b/tests/app/routers/test_images.py @@ -325,10 +325,13 @@ def test_star_unstar_dedupes_repeated_names( "/api/v1/images/unstar", "/api/v1/images/images_by_names", "/api/v1/images/download", + "/api/v1/board_images/batch", + "/api/v1/board_images/batch/delete", ], ) def test_image_name_batches_are_bounded(monkeypatch: Any, mock_invoker: Invoker, client: TestClient, path: str) -> None: - """An unbounded name list is a free amplification: each name costs a DB lookup.""" + """An unbounded name list is a free amplification: each name costs at least one DB lookup, + and up to six when the caller is reading someone else's shared board.""" images_service = prepare_image_batch_test(monkeypatch, mock_invoker) # /download would otherwise authorize every name and schedule a background task; if the # bound ever regresses we want the assertion below to fail, not the service call to blow up. @@ -336,18 +339,21 @@ def test_image_name_batches_are_bounded(monkeypatch: Any, mock_invoker: Invoker, bulk_download.generate_item_id.return_value = "test" monkeypatch.setattr(mock_invoker.services, "bulk_download", bulk_download) - response = client.post( - path, json={"image_names": [f"image-{index}.png" for index in range(MAX_IMAGE_BATCH_SIZE + 1)]} - ) + body: dict[str, Any] = {"image_names": [f"image-{index}.png" for index in range(MAX_IMAGE_BATCH_SIZE + 1)]} + if path == "/api/v1/board_images/batch": + body["board_id"] = "board-1" + response = client.post(path, json=body) assert response.status_code == 422 - response = client.post(path, json={"image_names": ["x" * 256]}) + response = client.post(path, json={**body, "image_names": ["x" * 256]}) assert response.status_code == 422 - # Rejection is FastAPI request validation, so it happens before the route body runs: - # no per-name authorization lookups and no background task were scheduled. - assert images_service.get_dto.call_count == 0 - assert bulk_download.generate_item_id.call_count == 0 + # Rejection is FastAPI request validation, so it lands before the route body runs at all. + # These two only bite on the /v1/images routes -- the board_images router reads its own + # module-level ApiDependencies, which prepare_image_batch_test does not patch -- but they + # are what catches a bound that gets "enforced" inside the handler instead of on the body. + assert images_service.mock_calls == [] + assert bulk_download.mock_calls == [] def test_every_image_names_body_is_bounded(client: TestClient) -> None: @@ -355,22 +361,15 @@ def test_every_image_names_body_is_bounded(client: TestClient) -> None: /download shipped unbounded because the limits were applied route-by-route rather than to the shape. Rather than restate the limit on every route, assert the published - contract: every images-router request body that takes an `image_names` array declares - both a list bound and a per-name length bound. - - Scoped to /v1/images deliberately. The two /v1/board_images batch routes are unbounded - too, but bounding them would reject a >MAX_IMAGE_BATCH_SIZE change-board request that - the UI can produce today, so they are left for a follow-up that pairs the bound with - client-side chunking. + contract: every request body that takes an `image_names` array declares both a list + bound and a per-name length bound. """ schema = client.get("/openapi.json").json() components = schema["components"]["schemas"] unbounded: list[str] = [] - checked = 0 + checked: list[str] = [] for path, operations in schema["paths"].items(): - if not path.startswith("/api/v1/images/"): - continue for method, operation in operations.items(): ref = ( operation.get("requestBody", {}) @@ -390,12 +389,23 @@ def test_every_image_names_body_is_bounded(client: TestClient) -> None: array = next((variant for variant in variants if variant.get("type") == "array"), None) if array is None: continue - checked += 1 + checked.append(path) if array.get("maxItems") is None or array.get("items", {}).get("maxLength") is None: unbounded.append(f"{method.upper()} {path}") - # Floor guards against the walk silently matching nothing if the schema shape changes. - assert checked >= 5 + # Pin the exact route set rather than a floor. A floor cannot see the failure this test + # exists to catch: a route the walk *skips* (a body that nests image_names inside a model + # rather than declaring it flat with Body(embed=True)) leaves the count unchanged and the + # guard green. Adding a route here is deliberate — bound it, then add it to this list. + assert sorted(checked) == [ + "/api/v1/board_images/batch", + "/api/v1/board_images/batch/delete", + "/api/v1/images/delete", + "/api/v1/images/download", + "/api/v1/images/images_by_names", + "/api/v1/images/star", + "/api/v1/images/unstar", + ] assert unbounded == [], f"unbounded image_names batch bodies: {unbounded}" From 18ef116253b41a1acbeb0eefd103519fb7aa04b8 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sun, 16 Aug 2026 23:39:14 -0400 Subject: [PATCH 05/34] fix(api): board batch moves skip foreign names instead of aborting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends §1 of this PR to the two routes it missed. `add_images_to_board` and `remove_images_from_board` did `except HTTPException: raise` inside the per-name loop, so one name the caller doesn't own — or one deleted by a concurrent session — discarded the response payload for every image that had *already* been moved in the same request. Those moves are committed; only the report is lost, so the client never invalidated their caches and the UI kept showing them on their old board until a full refresh. Both now skip such names and dedup repeated ones, matching star/unstar/delete. `AddImagesToBoardResult` / `RemoveImagesFromBoardResult` gain the `failed_images` list the other batch results already carry, populated for genuine storage failures only — an auth skip is not a failure and must not be toasted as one. The field is required rather than defaulted because the client toasts off it, and an optional one reaches TypeScript as `undefined`; the single-image routes pass an empty list, where a failure is a 500 and never a partial success. `DeleteImagesResult` is tightened the same way, and delete finally toasts its partial failures — it never did, and `handleDeletions` swallows every outcome, so a delete that only partly landed said nothing at all. Skipping removes the early abort that used to cap an unauthorized batch at one check, so `remove_images_from_board` memoizes board write-access per board id. `_assert_board_write_access` goes through `boards.get_dto()` — six queries, three of them COUNT aggregates over the board's contents — and both routes are `async def` with synchronous DB calls, so unmemoized a 1000-name batch on one board is ~6000 blocking queries on the event loop: exactly what the bound in the previous commit exists to prevent. The check sits outside the per-name try so there is precisely one way to skip a name for authorization; folding it in left two paths to the same outcome and neither was individually load-bearing. `remove_images_from_board` resolves the DTO in its own block, narrowed to `ImageRecordNotFoundException`. It is the one route that reads the DTO *before* any authorization check, so an image deleted between the client building its selection and this request would otherwise be indistinguishable from a storage failure and toasted as one — while a real storage error must still reach `failed_images`. The maintenance pre-check loop skips the same exception: raised from inside an `except HTTPException:` handler it would replace the 409 with a 500. The authorization guarantees are unchanged — only the reporting is. `test_non_owner_cannot_batch_add_other_users_images_to_own_board` is updated for the new shape and asserts the move was never *attempted*, since `board_images` is a MagicMock in that fixture and asserting on `board_image_records` would pass no matter what the route did. Three new tests cover the remove side, which had no authorization test at all — and could not have had one: the fixture left `urls` as None, so `ImageService.get_dto` raised `AttributeError` for every image and every name was skipped before the ownership check ran, passing regardless of what the route did. Co-Authored-By: Claude Opus 5 (1M context) --- invokeai/app/api/routers/board_images.py | 81 +++++++-- invokeai/app/api/routers/images.py | 3 + invokeai/app/services/images/images_common.py | 11 +- invokeai/frontend/web/openapi.json | 22 ++- .../src/services/api/endpoints/images.test.ts | 31 ++-- .../web/src/services/api/endpoints/images.ts | 66 ++++++-- .../frontend/web/src/services/api/schema.ts | 12 +- .../routers/test_multiuser_authorization.py | 154 +++++++++++++++++- 8 files changed, 338 insertions(+), 42 deletions(-) diff --git a/invokeai/app/api/routers/board_images.py b/invokeai/app/api/routers/board_images.py index 8eeb8efb106..f030fce0b1e 100644 --- a/invokeai/app/api/routers/board_images.py +++ b/invokeai/app/api/routers/board_images.py @@ -5,6 +5,7 @@ from invokeai.app.api.dependencies import ApiDependencies from invokeai.app.api.routers.image_move_maintenance import assert_image_move_maintenance_inactive from invokeai.app.api.routers.images import MAX_IMAGE_BATCH_SIZE, ImageName +from invokeai.app.services.image_records.image_records_common import ImageRecordNotFoundException from invokeai.app.services.images.images_common import AddImagesToBoardResult, RemoveImagesFromBoardResult board_images_router = APIRouter(prefix="/v1/board_images", tags=["boards"]) @@ -79,6 +80,8 @@ async def add_image_to_board( return AddImagesToBoardResult( added_images=list(added_images), + # Single-image route: a failure here is a 500, never a partial success. + failed_images=[], affected_boards=list(affected_boards), ) except Exception: @@ -112,6 +115,8 @@ async def remove_image_from_board( affected_boards.add(old_board_id) return RemoveImagesFromBoardResult( removed_images=list(removed_images), + # Single-image route: a failure here is a 500, never a partial success. + failed_images=[], affected_boards=list(affected_boards), ) @@ -147,9 +152,17 @@ async def add_images_to_board( raise try: + # Skip — but do not re-raise — auth failures so a foreign name mid-batch doesn't + # discard the response payload for images that were already moved. Re-raising turned + # partial successes into an error-shaped response, so the client never invalidated + # caches for the images that did move and the UI kept showing them on their old board + # until the next full refresh. Matches star_images_in_list and delete_images_from_list. added_images: set[str] = set() + failed_images: set[str] = set() affected_boards: set[str] = set() - for image_name in image_names: + # Dedup while preserving order — a repeated name would otherwise be processed twice + # and could land in both added_images and failed_images. + for image_name in dict.fromkeys(image_names): try: _assert_image_direct_owner(image_name, current_user) old_board_id = ( @@ -164,11 +177,15 @@ async def add_images_to_board( affected_boards.add(old_board_id) except HTTPException: - raise + continue except Exception: - pass + # A genuine storage failure, not an auth/404 skip: it used to be swallowed by + # `pass`, so the client counted the image as moved and the move silently + # reverted on reload. + failed_images.add(image_name) return AddImagesToBoardResult( added_images=list(added_images), + failed_images=list(failed_images), affected_boards=list(affected_boards), ) except HTTPException: @@ -197,29 +214,73 @@ async def remove_images_from_board( assert_image_move_maintenance_inactive() except HTTPException: for image_name in image_names: - old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none" + try: + old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none" + except ImageRecordNotFoundException: + # A name deleted by a concurrent session. The main loop treats that as a skip; + # letting it escape from inside this handler would replace the 409 with a 500. + continue if old_board_id != "none": _assert_board_write_access(old_board_id, current_user) raise try: + # Skip — but do not re-raise — auth failures, for the same reason as add_images_to_board + # above: one name on a board the caller cannot write must not discard the payload for + # the images already removed. removed_images: set[str] = set() + failed_images: set[str] = set() affected_boards: set[str] = set() - for image_name in image_names: + # Decided once per board rather than once per name. The skip removed the early abort + # that used to cap an unauthorized batch at one check, and _assert_board_write_access + # goes through boards.get_dto() -- six queries including three COUNT aggregates over + # the board's contents. Unmemoized, a 1000-name batch on one board is 6000 synchronous + # queries on the event loop, which is exactly what MAX_IMAGE_BATCH_SIZE exists to stop. + board_is_writable: dict[str, bool] = {} + + def _may_write(board_id: str) -> bool: + if board_id not in board_is_writable: + try: + _assert_board_write_access(board_id, current_user) + board_is_writable[board_id] = True + except HTTPException: + board_is_writable[board_id] = False + return board_is_writable[board_id] + + # Dedup while preserving order — a repeated name would otherwise be processed twice + # and could land in both removed_images and failed_images. + for image_name in dict.fromkeys(image_names): try: old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none" - if old_board_id != "none": - _assert_board_write_access(old_board_id, current_user) + except ImageRecordNotFoundException: + # The image is gone — deleted by a concurrent session between the client + # building its selection and this request. That is a skip, not a failure, and + # must not be toasted as one. Resolved in its own block because unlike the + # other routes this one reads the DTO *before* any authorization check, so a + # 404 here would otherwise be indistinguishable from a storage failure below. + # Narrow on purpose: a real storage error must still reach failed_images. + continue + except Exception: + failed_images.add(image_name) + continue + + # The one authorization decision, outside the try below so that the only way to + # skip a name for auth is this branch. Folding it into the try would leave two + # paths to the same outcome, and neither would be individually load-bearing. + if old_board_id != "none" and not _may_write(old_board_id): + continue + + try: ApiDependencies.invoker.services.board_images.remove_image_from_board(image_name=image_name) removed_images.add(image_name) affected_boards.add("none") affected_boards.add(old_board_id) - except HTTPException: - raise except Exception: - pass + # A genuine storage failure, not an auth/404 skip — see add_images_to_board. + failed_images.add(image_name) return RemoveImagesFromBoardResult( removed_images=list(removed_images), + failed_images=list(failed_images), affected_boards=list(affected_boards), ) except HTTPException: diff --git a/invokeai/app/api/routers/images.py b/invokeai/app/api/routers/images.py index d95cab4c5e6..44d94afd284 100644 --- a/invokeai/app/api/routers/images.py +++ b/invokeai/app/api/routers/images.py @@ -237,6 +237,9 @@ async def delete_image( return DeleteImagesResult( deleted_images=list(deleted_images), + # Single-image route: the swallowed failure above already leaves deleted_images empty, + # which is how this route has always reported it. + failed_images=[], affected_boards=list(affected_boards), ) diff --git a/invokeai/app/services/images/images_common.py b/invokeai/app/services/images/images_common.py index 1acd42fac23..4c96fd825f1 100644 --- a/invokeai/app/services/images/images_common.py +++ b/invokeai/app/services/images/images_common.py @@ -47,10 +47,9 @@ class ResultWithAffectedBoards(BaseModel): class DeleteImagesResult(ResultWithAffectedBoards): deleted_images: list[str] = Field(description="The names of the images that were deleted") - failed_images: list[str] = Field( - default_factory=list, - description="The names of authorized images that could not be deleted", - ) + # Required, not defaulted: the client toasts off this field, and an optional one would reach + # it as `undefined`. Matches StarredImagesResult. + failed_images: list[str] = Field(description="The names of authorized images that could not be deleted") class StarredImagesResult(ResultWithAffectedBoards): @@ -65,7 +64,11 @@ class UnstarredImagesResult(ResultWithAffectedBoards): class AddImagesToBoardResult(ResultWithAffectedBoards): added_images: list[str] = Field(description="The image names that were added to the board") + # Required, not defaulted: the client toasts off this field, and an optional one would reach + # it as `undefined`. Matches StarredImagesResult. + failed_images: list[str] = Field(description="The names of authorized images that could not be added") class RemoveImagesFromBoardResult(ResultWithAffectedBoards): removed_images: list[str] = Field(description="The image names that were removed from their board") + failed_images: list[str] = Field(description="The names of authorized images that could not be removed") diff --git a/invokeai/frontend/web/openapi.json b/invokeai/frontend/web/openapi.json index fabae19aea2..f20143b433a 100644 --- a/invokeai/frontend/web/openapi.json +++ b/invokeai/frontend/web/openapi.json @@ -12515,10 +12515,18 @@ "type": "array", "title": "Added Images", "description": "The image names that were added to the board" + }, + "failed_images": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Failed Images", + "description": "The names of authorized images that could not be added" } }, "type": "object", - "required": ["affected_boards", "added_images"], + "required": ["affected_boards", "added_images", "failed_images"], "title": "AddImagesToBoardResult" }, "AddInvocation": { @@ -23765,7 +23773,7 @@ } }, "type": "object", - "required": ["affected_boards", "deleted_images"], + "required": ["affected_boards", "deleted_images", "failed_images"], "title": "DeleteImagesResult" }, "DeleteOrphanedModelsRequest": { @@ -75241,10 +75249,18 @@ "type": "array", "title": "Removed Images", "description": "The image names that were removed from their board" + }, + "failed_images": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Failed Images", + "description": "The names of authorized images that could not be removed" } }, "type": "object", - "required": ["affected_boards", "removed_images"], + "required": ["affected_boards", "removed_images", "failed_images"], "title": "RemoveImagesFromBoardResult" }, "RemoveVideosFromBoardResult": { diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts index 82bc5499f54..436c6969a56 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts @@ -1,5 +1,4 @@ import { toast } from 'features/toast/toast'; -import i18n from 'i18next'; import { buildChunkedImageBatchQueryFn, chunkImageNames, mergeImageBatchResults } from 'services/api/endpoints/images'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -50,7 +49,7 @@ describe('mergeImageBatchResults', () => { describe('buildChunkedImageBatchQueryFn', () => { type Arg = { image_names: string[]; board_id?: string }; - type Result = { added_images: string[]; affected_boards: string[] }; + type Result = { added_images: string[]; failed_images: string[]; affected_boards: string[] }; type Request = { url: string; method: string; body: Arg }; type Response = { data: Result } | { error: { status: number; data: string } }; @@ -58,7 +57,6 @@ describe('buildChunkedImageBatchQueryFn', () => { beforeEach(() => { vi.mocked(toast).mockClear(); - vi.mocked(i18n.t).mockClear(); }); const run = (baseQuery: (args: Request) => Promise, arg: Arg) => { @@ -74,7 +72,8 @@ describe('buildChunkedImageBatchQueryFn', () => { it('sends one request per chunk, carrying the non-name body fields on each', async () => { const baseQuery = vi.fn( - (_args: Request): Promise => Promise.resolve({ data: { added_images: [], affected_boards: [] } }) + (_args: Request): Promise => + Promise.resolve({ data: { added_images: [], failed_images: [], affected_boards: [] } }) ); const { result } = run(baseQuery, { image_names: names(2500), board_id: 'board-1' }); @@ -90,13 +89,15 @@ describe('buildChunkedImageBatchQueryFn', () => { let call = 0; const baseQuery = vi.fn((_args: Request): Promise => { call += 1; - return Promise.resolve({ data: { added_images: [`chunk-${call}.png`], affected_boards: ['board-1'] } }); + return Promise.resolve({ + data: { added_images: [`chunk-${call}.png`], failed_images: [], affected_boards: ['board-1'] }, + }); }); const { result } = run(baseQuery, { image_names: names(1500) }); expect(await result).toEqual({ - data: { added_images: ['chunk-1.png', 'chunk-2.png'], affected_boards: ['board-1'] }, + data: { added_images: ['chunk-1.png', 'chunk-2.png'], failed_images: [], affected_boards: ['board-1'] }, }); }); @@ -111,19 +112,27 @@ describe('buildChunkedImageBatchQueryFn', () => { if (call === 3) { return Promise.resolve({ error: { status: 500, data: 'boom' } }); } - return Promise.resolve({ data: { added_images: [`chunk-${call}.png`], affected_boards: ['board-1'] } }); + return Promise.resolve({ + data: { added_images: [`chunk-${call}.png`], failed_images: [], affected_boards: ['board-1'] }, + }); }); const { dispatch, result } = run(baseQuery, { image_names: names(2500) }); + // The failing chunk's 500 names are unreached, not merely un-reported, so they are folded + // into failed_images -- one place, so the endpoint's single toast reports one true total. expect(await result).toEqual({ - data: { added_images: ['chunk-1.png', 'chunk-2.png'], affected_boards: ['board-1'] }, + data: { + added_images: ['chunk-1.png', 'chunk-2.png'], + failed_images: names(2500).slice(2000), + affected_boards: ['board-1'], + }, }); expect(baseQuery).toHaveBeenCalledTimes(3); // stopped, did not keep firing chunks expect(dispatch).toHaveBeenCalledWith(api.util.invalidateTags(getTags())); - // The failing chunk's 500 names are unreached, not merely un-reported. - expect(toast).toHaveBeenCalledWith(expect.objectContaining({ id: 'IMAGES_FAILED_TO_UPDATE', status: 'warning' })); - expect(i18n.t).toHaveBeenCalledWith('toast.imagesFailedToUpdate', { count: 500 }); + // Toasting from here as well would fire twice on one toast id, and the toast system + // updates in place -- the second count would replace the first rather than adding to it. + expect(toast).not.toHaveBeenCalled(); }); it('reports an error when the first chunk fails, since nothing was applied', async () => { diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index 52c96c89b04..502b3e89724 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -59,8 +59,12 @@ const buildBoardImagesUrl = (path: string = '') => buildV1Url(`board_images/${pa */ const IMAGE_BATCH_CHUNK_SIZE = 1000; -/** Every batch route answers with an object whose values are all name lists. */ -type ImageBatchResult = Record; +/** + * Every batch route answers with an object whose values are all name lists, and every one of + * them reports its per-name failures in `failed_images` — which is where a chunked run folds + * the names it never reached. + */ +type ImageBatchResult = Record & { failed_images: string[] }; type InvalidateTagsArg = Parameters[0]; @@ -90,7 +94,8 @@ export const chunkImageNames = (image_names: string[]): string[][] => { * (`deleted_images`, `starred_images`, `added_images`, ...) while sharing `affected_boards`. */ export const mergeImageBatchResults = (results: TResult[]): TResult => { - const merged: ImageBatchResult = {}; + // Accumulator is the looser Record: it is only complete once every chunk has contributed. + const merged: Record = {}; for (const result of results) { for (const [key, names] of Object.entries(result)) { merged[key] = merged[key] ? uniq(merged[key].concat(names)) : names; @@ -112,8 +117,13 @@ export const mergeImageBatchResults = (results * routes exists to fix. It is not only the RTK cache at stake — `handleDeletions` drives the * gallery selection and strips deleted images out of nodes, canvas layers and reference images * off `result.deleted_images`, and none of that runs on a rejection. So the merged result is - * returned, the unreached names are toasted as failures, and only a run where *nothing* landed - * surfaces as an error. + * returned, the unreached names are folded into its `failed_images`, and only a run where + * *nothing* landed surfaces as an error. + * + * They are folded into the payload rather than toasted here so that a single `onQueryStarted` + * reports one total. Toasting from both places would fire twice on the same toast id, and the + * toast system updates in place and appends "(2)" — so the second count would simply replace + * the first rather than adding to it. */ export const buildChunkedImageBatchQueryFn = ( @@ -137,9 +147,9 @@ export const buildChunkedImageBatchQueryFn = } // Everything from this chunk on is unreached, not merely un-reported. const unreached = chunks.slice(index).flat(); - toastFailedImages(unreached.length); - dispatch(api.util.invalidateTags(getTags(mergeImageBatchResults(results)))); - return { data: mergeImageBatchResults(results) }; + const merged = mergeImageBatchResults(results); + dispatch(api.util.invalidateTags(getTags(merged))); + return { data: { ...merged, failed_images: uniq(merged.failed_images.concat(unreached)) } as TResult }; } results.push(response.data as TResult); } @@ -293,6 +303,18 @@ export const imagesApi = api.injectEndpoints({ () => ({ url: buildImagesUrl('delete'), method: 'POST' }), getDeleteImagesTags ), + async onQueryStarted(_, { queryFulfilled }) { + try { + const { data: result } = await queryFulfilled; + // `handleDeletions` swallows every outcome, so without this a delete that only + // partly landed — server-side failures or chunks never reached — said nothing at all. + toastFailedImages(result.failed_images.length); + } catch { + // A rejection means nothing landed at all -- a partial run resolves with data, + // not an error. Nothing toasts these rejections today; that gap is unchanged + // by this handler, which exists only to surface per-name failures. + } + }, invalidatesTags: (result) => (result ? getDeleteImagesTags(result) : []), }), deleteUncategorizedImages: build.mutation< @@ -352,7 +374,9 @@ export const imagesApi = api.injectEndpoints({ const { data: result } = await queryFulfilled; toastFailedImages(result.failed_images.length); } catch { - // Global API error handling reports request-level failures. + // A rejection means nothing landed at all -- a partial run resolves with data, + // not an error. Nothing toasts these rejections today; that gap is unchanged + // by this handler, which exists only to surface per-name failures. } }, invalidatesTags: (result) => (result ? getStarImagesTags(result) : []), @@ -373,7 +397,9 @@ export const imagesApi = api.injectEndpoints({ const { data: result } = await queryFulfilled; toastFailedImages(result.failed_images.length); } catch { - // Global API error handling reports request-level failures. + // A rejection means nothing landed at all -- a partial run resolves with data, + // not an error. Nothing toasts these rejections today; that gap is unchanged + // by this handler, which exists only to surface per-name failures. } }, invalidatesTags: (result) => (result ? getUnstarImagesTags(result) : []), @@ -540,6 +566,16 @@ export const imagesApi = api.injectEndpoints({ () => ({ url: buildBoardImagesUrl('batch'), method: 'POST' }), getAddImagesToBoardTags ), + async onQueryStarted(_, { queryFulfilled }) { + try { + const { data: result } = await queryFulfilled; + toastFailedImages(result.failed_images.length); + } catch { + // A rejection means nothing landed at all -- a partial run resolves with data, + // not an error. Nothing toasts these rejections today; that gap is unchanged + // by this handler, which exists only to surface per-name failures. + } + }, invalidatesTags: (result) => (result ? getAddImagesToBoardTags(result) : []), }), removeImagesFromBoard: build.mutation< @@ -550,6 +586,16 @@ export const imagesApi = api.injectEndpoints({ () => ({ url: buildBoardImagesUrl('batch/delete'), method: 'POST' }), getRemoveImagesFromBoardTags ), + async onQueryStarted(_, { queryFulfilled }) { + try { + const { data: result } = await queryFulfilled; + toastFailedImages(result.failed_images.length); + } catch { + // A rejection means nothing landed at all -- a partial run resolves with data, + // not an error. Nothing toasts these rejections today; that gap is unchanged + // by this handler, which exists only to surface per-name failures. + } + }, invalidatesTags: (result) => (result ? getRemoveImagesFromBoardTags(result) : []), }), bulkDownloadImages: build.mutation< diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index a10681a0842..864b88afff9 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -3316,6 +3316,11 @@ export type components = { * @description The image names that were added to the board */ added_images: string[]; + /** + * Failed Images + * @description The names of authorized images that could not be added + */ + failed_images: string[]; }; /** * Add Integers @@ -9061,7 +9066,7 @@ export type components = { * Failed Images * @description The names of authorized images that could not be deleted */ - failed_images?: string[]; + failed_images: string[]; }; /** * DeleteOrphanedModelsRequest @@ -32124,6 +32129,11 @@ export type components = { * @description The image names that were removed from their board */ removed_images: string[]; + /** + * Failed Images + * @description The names of authorized images that could not be removed + */ + failed_images: string[]; }; /** RemoveVideosFromBoardResult */ RemoveVideosFromBoardResult: { diff --git a/tests/app/routers/test_multiuser_authorization.py b/tests/app/routers/test_multiuser_authorization.py index 207e295d787..41cf459036c 100644 --- a/tests/app/routers/test_multiuser_authorization.py +++ b/tests/app/routers/test_multiuser_authorization.py @@ -65,6 +65,13 @@ def client(): return TestClient(app) +def _mock_urls() -> MagicMock: + """A urls service whose getters return real strings, so ImageDTO validates.""" + urls = MagicMock() + urls.get_image_url.return_value = "http://test.invalid/image.png" + return urls + + @pytest.fixture def mock_services() -> InvocationServices: from invokeai.app.services.board_image_records.board_image_records_sqlite import SqliteBoardImageRecordStorage @@ -110,7 +117,12 @@ def mock_services() -> InvocationServices: performance_statistics=InvocationStatsService(), session_processor=None, # type: ignore session_queue=None, # type: ignore - urls=None, # type: ignore + # Real enough for ImageService.get_dto to build a DTO. With None it raised + # AttributeError for *every* image, so any route resolving a DTO silently took its + # not-found path — which made board_images' batch-delete authorization untestable: + # every name was skipped before the ownership check ran, and the test passed no + # matter what the route did. The returns must be strings; ImageDTO validates them. + urls=_mock_urls(), workflow_records=SqliteWorkflowRecordsStorage(db=db), tensors=None, # type: ignore conditioning=None, # type: ignore @@ -377,7 +389,14 @@ def test_non_owner_cannot_add_other_users_image_to_own_board( def test_non_owner_cannot_batch_add_other_users_images_to_own_board( self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str ): - """Same attack via the batch endpoint.""" + """Same attack via the batch endpoint. + + Batch add skips foreign names instead of re-raising the first 403 — same rationale as + test_non_owner_cannot_star_image: re-raising mid-batch discarded the partial successes, + so the client never learned which images HAD moved. Only the response shape changes. + The attack itself must still fail: the victim's image must not move, and must not be + advertised as added. + """ user1 = mock_invoker.services.users.get_by_email("user1@test.com") assert user1 is not None _save_image(mock_invoker, "victim-batch-img", user1.user_id) @@ -389,7 +408,136 @@ def test_non_owner_cannot_batch_add_other_users_images_to_own_board( json={"board_id": attacker_board, "image_names": ["victim-batch-img"]}, headers=_auth(user2_token), ) - assert r.status_code == status.HTTP_403_FORBIDDEN + assert r.status_code == status.HTTP_201_CREATED + body = r.json() + assert body["added_images"] == [] + # An auth skip is not a failure — it must not be reported (and toasted) as one. + assert body["failed_images"] == [] + # `board_images` is a MagicMock in this fixture, so asserting on board_image_records + # would pass no matter what the route did. Assert the move was never attempted. + mock_invoker.services.board_images.add_image_to_board.assert_not_called() + + def test_batch_add_keeps_partial_successes_when_one_name_is_foreign( + self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str + ): + """The point of the skip: the attacker's own image still moves, and is reported. + + Before, the first foreign name re-raised and discarded the payload for every image + that had already been moved in the same request, so the client never invalidated + their caches and the UI kept showing them on their old board. + """ + user1 = mock_invoker.services.users.get_by_email("user1@test.com") + user2 = mock_invoker.services.users.get_by_email("user2@test.com") + assert user1 is not None and user2 is not None + _save_image(mock_invoker, "victim-mixed-img", user1.user_id) + _save_image(mock_invoker, "own-mixed-img", user2.user_id) + + board_id = _create_board(client, user2_token, "Mixed Batch Board") + mock_invoker.services.board_images.add_image_to_board.reset_mock() + + # Foreign name first, so the old `raise` would abort before reaching the owned one. + r = client.post( + "/api/v1/board_images/batch", + json={"board_id": board_id, "image_names": ["victim-mixed-img", "own-mixed-img"]}, + headers=_auth(user2_token), + ) + assert r.status_code == status.HTTP_201_CREATED + body = r.json() + assert body["added_images"] == ["own-mixed-img"] + assert body["failed_images"] == [] + assert board_id in body["affected_boards"] + # Exactly one move attempted, and only for the caller's own image. + assert [ + call.kwargs["image_name"] for call in mock_invoker.services.board_images.add_image_to_board.call_args_list + ] == ["own-mixed-img"] + + def test_non_owner_cannot_batch_remove_images_from_foreign_board( + self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str + ): + """Batch remove skips names on boards the caller cannot write, instead of re-raising. + + The guarantee is unchanged: the image stays on the board and is not advertised as + removed. Only the response shape changes. + """ + user1 = mock_invoker.services.users.get_by_email("user1@test.com") + assert user1 is not None + _save_image(mock_invoker, "victim-remove-img", user1.user_id) + board_id = _create_board(client, user1_token, "User1 Private Remove Board") + mock_invoker.services.board_image_records.add_image_to_board(board_id, "victim-remove-img") + mock_invoker.services.board_images.remove_image_from_board.reset_mock() + + r = client.post( + "/api/v1/board_images/batch/delete", + json={"image_names": ["victim-remove-img"]}, + headers=_auth(user2_token), + ) + assert r.status_code == status.HTTP_201_CREATED + body = r.json() + assert body["removed_images"] == [] + # An auth skip is not a failure — it must not be reported (and toasted) as one. + assert body["failed_images"] == [] + mock_invoker.services.board_images.remove_image_from_board.assert_not_called() + + def test_batch_remove_keeps_partial_successes_when_one_name_is_foreign( + self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str + ): + """The point of the skip, on the remove side: the caller's own image still comes off.""" + user1 = mock_invoker.services.users.get_by_email("user1@test.com") + user2 = mock_invoker.services.users.get_by_email("user2@test.com") + assert user1 is not None and user2 is not None + _save_image(mock_invoker, "victim-rm-mixed", user1.user_id) + _save_image(mock_invoker, "own-rm-mixed", user2.user_id) + victim_board = _create_board(client, user1_token, "User1 Board For Remove Mix") + own_board = _create_board(client, user2_token, "User2 Board For Remove Mix") + mock_invoker.services.board_image_records.add_image_to_board(victim_board, "victim-rm-mixed") + mock_invoker.services.board_image_records.add_image_to_board(own_board, "own-rm-mixed") + mock_invoker.services.board_images.remove_image_from_board.reset_mock() + + # Foreign name first, so the old `raise` would abort before reaching the owned one. + r = client.post( + "/api/v1/board_images/batch/delete", + json={"image_names": ["victim-rm-mixed", "own-rm-mixed"]}, + headers=_auth(user2_token), + ) + assert r.status_code == status.HTTP_201_CREATED + body = r.json() + assert body["removed_images"] == ["own-rm-mixed"] + assert body["failed_images"] == [] + assert own_board in body["affected_boards"] + assert victim_board not in body["affected_boards"] + assert [ + call.kwargs["image_name"] + for call in mock_invoker.services.board_images.remove_image_from_board.call_args_list + ] == ["own-rm-mixed"] + + def test_batch_remove_checks_each_board_once( + self, client: TestClient, mock_invoker: Invoker, monkeypatch: Any, user1_token: str, user2_token: str + ): + """Skipping removed the early abort, so the per-board check must be memoized. + + Without it an unauthorized batch pays boards.get_dto() -- six queries, three of them + COUNT aggregates over the board -- once per name, synchronously, on the event loop. + """ + user1 = mock_invoker.services.users.get_by_email("user1@test.com") + assert user1 is not None + board_id = _create_board(client, user1_token, "User1 Board Memoized") + names = [f"victim-memo-{index}" for index in range(5)] + for name in names: + _save_image(mock_invoker, name, user1.user_id) + mock_invoker.services.board_image_records.add_image_to_board(board_id, name) + + real_get_dto = mock_invoker.services.boards.get_dto + spy = MagicMock(side_effect=real_get_dto) + monkeypatch.setattr(mock_invoker.services.boards, "get_dto", spy) + + r = client.post( + "/api/v1/board_images/batch/delete", + json={"image_names": names}, + headers=_auth(user2_token), + ) + assert r.status_code == status.HTTP_201_CREATED + assert r.json()["removed_images"] == [] + assert spy.call_count == 1 # =========================================================================== From c3d2ad4a8c4f72bb1a4d4a77d65c4ea95894ac18 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Tue, 18 Aug 2026 23:38:05 -0400 Subject: [PATCH 06/34] fix(api): treat a mid-batch not-found as a skip, not a storage failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/images/delete`, `/star` and `/unstar` reported an `ImageRecordNotFoundException` raised after the ownership check in `failed_images`, so an image deleted by a concurrent session between the client building its selection and the request landing toasted "1 image could not be updated" for an outcome the user actually got. `remove_images_from_board` already resolves the same race as a skip; these three now match it, and the name is absent from both result lists. Star/unstar reach the race through the `get_dto` read-back inside `ImageService.update`: the UPDATE matches no row and raises nothing, so a name that vanished mid-batch surfaces only on the read that follows. The skip is only sound if the exception means what its name says, and it did not: `image_records.get()` re-raised every `sqlite3.Error` as `ImageRecordNotFoundException`, so a locked, corrupt or unreadable database was indistinguishable from a concurrent delete. Under the new skip that would have turned a wholly failed batch into 200 with two empty lists and no toast at all — strictly worse than the spurious warning the skip removes. The translation is dropped in `get()` and `get_metadata()`; storage errors now propagate as themselves, which also un-swallows them for the two existing skips in `board_images.py`. Co-Authored-By: Claude Opus 5 (1M context) --- invokeai/app/api/routers/images.py | 22 +++ .../image_records/image_records_sqlite.py | 47 +++---- tests/app/routers/test_images.py | 128 ++++++++++++++++++ 3 files changed, 174 insertions(+), 23 deletions(-) diff --git a/invokeai/app/api/routers/images.py b/invokeai/app/api/routers/images.py index 44d94afd284..bde8f468aa4 100644 --- a/invokeai/app/api/routers/images.py +++ b/invokeai/app/api/routers/images.py @@ -27,6 +27,7 @@ ImageCategory, ImageNamesResult, ImageRecordChanges, + ImageRecordNotFoundException, ResourceOrigin, ) from invokeai.app.services.images.images_common import ( @@ -541,6 +542,18 @@ async def delete_images_from_list( affected_boards.add(board_id) except HTTPException: continue + except ImageRecordNotFoundException: + # The record is already gone — a concurrent session deleted it after this + # iteration's ownership check passed. The caller asked for it to be gone and it + # is, so this is a skip, not a storage failure: reporting it in failed_images + # toasts "1 image could not be updated" for an outcome the user got. Matches + # remove_images_from_board, which resolves the same race for board removal. + # + # This is narrow only because image_records.get() no longer translates a + # sqlite3.Error into this exception — see the comment there. If that + # translation ever comes back, a locked or corrupt database would land here + # and a whole failed batch would answer 200 with empty result lists. + continue except Exception: # A genuine deletion failure (not an auth/404 skip) — report it so the # client can surface a partial-failure warning, matching the video path. @@ -631,6 +644,12 @@ async def star_images_in_list( affected_boards.add(updated_image_dto.board_id or "none") except HTTPException: continue + except ImageRecordNotFoundException: + # Deleted by a concurrent session — a skip, not a storage failure. See + # delete_images_from_list. Reachable here through the get_dto read-back inside + # ImageService.update: the UPDATE itself matches no row and raises nothing, so + # a name that vanished mid-batch surfaces only on the read that follows. + continue except Exception: # A genuine storage failure, not an auth/404 skip: it used to be swallowed # by `pass`, so the client counted the image as starred and the star @@ -677,6 +696,9 @@ async def unstar_images_in_list( affected_boards.add(updated_image_dto.board_id or "none") except HTTPException: continue + except ImageRecordNotFoundException: + # See star_images_in_list. + continue except Exception: failed_images.add(image_name) return UnstarredImagesResult( diff --git a/invokeai/app/services/image_records/image_records_sqlite.py b/invokeai/app/services/image_records/image_records_sqlite.py index a69bb7005de..ad5803eeb02 100644 --- a/invokeai/app/services/image_records/image_records_sqlite.py +++ b/invokeai/app/services/image_records/image_records_sqlite.py @@ -28,19 +28,23 @@ def __init__(self, db: SqliteDatabase) -> None: self._db = db def get(self, image_name: str) -> ImageRecord: + # A sqlite3.Error is deliberately NOT translated into ImageRecordNotFoundException. + # This used to be caught and re-raised as not-found, which made the exception mean + # "the row is absent, OR the database is locked/corrupt/unreadable". Callers that + # treat not-found as a benign outcome — the concurrent-deletion skips in the images + # and board_images batch routes — would then swallow a disk I/O error as a routine + # race and answer 200 with the name in no result list at all. Let the storage error + # propagate: ImageService logs it and the route reports it as a real failure. with self._db.transaction() as cursor: - try: - cursor.execute( - f"""--sql - SELECT {IMAGE_DTO_COLS} FROM images - WHERE image_name = ?; - """, - (image_name,), - ) + cursor.execute( + f"""--sql + SELECT {IMAGE_DTO_COLS} FROM images + WHERE image_name = ?; + """, + (image_name,), + ) - result = cast(Optional[sqlite3.Row], cursor.fetchone()) - except sqlite3.Error as e: - raise ImageRecordNotFoundException from e + result = cast(Optional[sqlite3.Row], cursor.fetchone()) if not result: raise ImageRecordNotFoundException @@ -62,20 +66,17 @@ def get_user_id(self, image_name: str) -> Optional[str]: return cast(Optional[str], dict(result).get("user_id")) def get_metadata(self, image_name: str) -> Optional[MetadataField]: + # See get(): a storage error must not masquerade as a missing row. with self._db.transaction() as cursor: - try: - cursor.execute( - """--sql - SELECT metadata FROM images - WHERE image_name = ?; - """, - (image_name,), - ) - - result = cast(Optional[sqlite3.Row], cursor.fetchone()) + cursor.execute( + """--sql + SELECT metadata FROM images + WHERE image_name = ?; + """, + (image_name,), + ) - except sqlite3.Error as e: - raise ImageRecordNotFoundException from e + result = cast(Optional[sqlite3.Row], cursor.fetchone()) if not result: raise ImageRecordNotFoundException diff --git a/tests/app/routers/test_images.py b/tests/app/routers/test_images.py index 8a9f3b32aa9..069f42ea2c7 100644 --- a/tests/app/routers/test_images.py +++ b/tests/app/routers/test_images.py @@ -1,4 +1,6 @@ import os +import sqlite3 +from contextlib import contextmanager from pathlib import Path from typing import Any from unittest.mock import MagicMock @@ -13,6 +15,7 @@ from invokeai.app.api_app import app from invokeai.app.services.auth.token_service import TokenData from invokeai.app.services.board_records.board_records_common import BoardRecord +from invokeai.app.services.image_records.image_records_common import ImageRecordNotFoundException from invokeai.app.services.images.images_common import ImageDTO from invokeai.app.services.invoker import Invoker from invokeai.app.services.shared.pagination import MAX_PAGE_SIZE, OffsetPaginatedResults @@ -301,6 +304,131 @@ def update(image_name: str, changes: Any) -> MagicMock: assert body["affected_boards"] == ["board-1"] +@pytest.mark.parametrize( + ("route", "updated_key"), + [("star", "starred_images"), ("unstar", "unstarred_images")], +) +def test_star_unstar_skips_names_deleted_mid_batch( + monkeypatch: Any, + mock_invoker: Invoker, + client: TestClient, + route: str, + updated_key: str, +) -> None: + """A name deleted by a concurrent session is a skip, not a storage failure. + + Reported as an admin, because that is the only caller for which this is reachable: for + anyone else `get_user_id` returns None for a missing record and the ownership check + already answers 403. It is also the default single-user path, so this WAS the common + case -- the name landed in failed_images and toasted "1 image could not be updated" + for an image the user no longer had. + """ + images_service = prepare_image_batch_test(monkeypatch, mock_invoker) + + def update(image_name: str, changes: Any) -> MagicMock: + del changes + if image_name == "vanished.png": + raise ImageRecordNotFoundException + dto = MagicMock() + dto.board_id = "board-1" + return dto + + images_service.update.side_effect = update + + response = client.post(f"/api/v1/images/{route}", json={"image_names": ["ok.png", "vanished.png"]}) + + assert response.status_code == 200 + body = response.json() + assert body[updated_key] == ["ok.png"] + assert body["failed_images"] == [] + + +@pytest.mark.parametrize("raise_from", ["get_dto", "delete"]) +def test_delete_skips_names_deleted_mid_batch( + monkeypatch: Any, mock_invoker: Invoker, client: TestClient, raise_from: str +) -> None: + """Same for /delete: the caller asked for the image to be gone, and it is. + + Parametrized over both raise sites because the race window spans them: the loop reads the + DTO for its board id and only then deletes, and ImageService.delete re-reads the record, so + a name can vanish after the first read succeeds. + """ + images_service = prepare_image_batch_test(monkeypatch, mock_invoker) + + def get_dto(image_name: str) -> MagicMock: + if image_name == "vanished.png" and raise_from == "get_dto": + raise ImageRecordNotFoundException + dto = MagicMock() + dto.board_id = "board-1" + return dto + + def delete(image_name: str) -> None: + if image_name == "vanished.png" and raise_from == "delete": + raise ImageRecordNotFoundException + + images_service.get_dto.side_effect = get_dto + images_service.delete.side_effect = delete + + response = client.post("/api/v1/images/delete", json={"image_names": ["ok.png", "vanished.png"]}) + + assert response.status_code == 200 + body = response.json() + assert body["deleted_images"] == ["ok.png"] + # Absent from both lists: not deleted by us, and not a failure either. + assert body["failed_images"] == [] + + +def test_image_records_get_does_not_disguise_a_storage_error_as_not_found(monkeypatch: Any) -> None: + """The narrowing itself: a sqlite3.Error out of the SELECT must stay a sqlite3.Error.""" + from invokeai.app.services.image_records.image_records_sqlite import SqliteImageRecordStorage + + storage = SqliteImageRecordStorage.__new__(SqliteImageRecordStorage) + + class _Cursor: + def execute(self, *args: Any, **kwargs: Any) -> None: + raise sqlite3.OperationalError("database disk image is malformed") + + class _Db: + @contextmanager + def transaction(self): + yield _Cursor() + + storage._db = _Db() # pyright: ignore[reportAttributeAccessIssue] + + with pytest.raises(sqlite3.OperationalError): + storage.get("a.png") + + +def test_delete_still_reports_a_genuine_storage_failure( + monkeypatch: Any, mock_invoker: Invoker, client: TestClient +) -> None: + """The not-found skip must stay narrow -- a real deletion failure is still reported. + + This is the half of the storage-error story the route owns. The other half is at the + storage layer: image_records.get() used to re-raise every sqlite3.Error as + ImageRecordNotFoundException, so a locked or corrupt database was indistinguishable from a + concurrent delete and the skip would have answered 200 with two empty lists and no toast at + all. See test_image_records_get_does_not_disguise_a_storage_error_as_not_found. + """ + images_service = prepare_image_batch_test(monkeypatch, mock_invoker) + dto = MagicMock() + dto.board_id = "board-1" + images_service.get_dto.return_value = dto + + def delete(image_name: str) -> None: + if image_name == "broken.png": + raise RuntimeError("storage is on fire") + + images_service.delete.side_effect = delete + + response = client.post("/api/v1/images/delete", json={"image_names": ["ok.png", "broken.png"]}) + + assert response.status_code == 200 + body = response.json() + assert body["deleted_images"] == ["ok.png"] + assert body["failed_images"] == ["broken.png"] + + @pytest.mark.parametrize("route", ["star", "unstar"]) def test_star_unstar_dedupes_repeated_names( monkeypatch: Any, mock_invoker: Invoker, client: TestClient, non_admin_user: None, route: str From 11021a9e22f3b3a2ce62fc7aca7d28b7f21e5bbc Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Tue, 18 Aug 2026 23:38:13 -0400 Subject: [PATCH 07/34] fix(ui): don't report a partially-scheduled bulk download as failed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chunked `/images/download` loop returned a bare error on any chunk failure. The route answers 202 the moment it has scheduled the background task, so every chunk before the failing one is already producing a zip: the user saw "Problem preparing download" from the `matchRejected` listener while those zips landed in their downloads anyway. It now follows `buildChunkedImageBatchQueryFn` — only a run where nothing was scheduled surfaces as an error; a partial run resolves with the first chunk's payload and warns with the count of names that made it into no zip. The warning has its own toast id, since the toast system updates in place and sharing `IMAGES_FAILED_TO_UPDATE` would let one count replace the other. "Something was scheduled" is tracked in its own flag rather than inferred from the payload: `fetchBaseQuery` resolves an empty response entity as `data: null`, so a 202 whose body did not survive the trip back leaves nothing to return even though the task was scheduled. The `queryFn` is extracted as `bulkDownloadQueryFn` so it can be tested. Co-Authored-By: Claude Opus 5 (1M context) --- invokeai/frontend/web/public/locales/en.json | 2 + .../src/services/api/endpoints/images.test.ts | 115 +++++++++++++++++- .../web/src/services/api/endpoints/images.ts | 109 ++++++++++++----- 3 files changed, 197 insertions(+), 29 deletions(-) diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index bfbf6352029..9ece3c0320c 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -2049,6 +2049,8 @@ "imageSavingFailed": "Image Saving Failed", "imageUploaded": "Image Uploaded", "imageUploadFailed": "Image Upload Failed", + "imagesFailedToDownload": "{{count}} image could not be prepared for download.", + "imagesFailedToDownload_other": "{{count}} images could not be prepared for download.", "imagesFailedToUpdate": "{{count}} image could not be updated.", "imagesFailedToUpdate_other": "{{count}} images could not be updated.", "videoUploaded": "Video Uploaded", diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts index 436c6969a56..f3fdefb6add 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts @@ -1,5 +1,11 @@ import { toast } from 'features/toast/toast'; -import { buildChunkedImageBatchQueryFn, chunkImageNames, mergeImageBatchResults } from 'services/api/endpoints/images'; +import i18n from 'i18next'; +import { + buildChunkedImageBatchQueryFn, + bulkDownloadQueryFn, + chunkImageNames, + mergeImageBatchResults, +} from 'services/api/endpoints/images'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { api } from '..'; @@ -147,3 +153,110 @@ describe('buildChunkedImageBatchQueryFn', () => { expect(toast).not.toHaveBeenCalled(); }); }); + +describe('bulkDownloadQueryFn', () => { + type Body = { image_names: string[]; board_id?: string }; + type Request = { url: string; method: string; body: Body }; + type Response = { data: { bulk_download_item_name: string } } | { error: { status: number; data: string } }; + + const run = (baseQuery: (args: Request) => Promise, body: Body) => + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + bulkDownloadQueryFn(body, undefined, undefined, baseQuery as any); + + beforeEach(() => { + vi.mocked(toast).mockClear(); + vi.mocked(i18n.t).mockClear(); + }); + + it('issues one request per chunk and returns the first item name', async () => { + let call = 0; + const baseQuery = vi.fn((_args: Request): Promise => { + call += 1; + return Promise.resolve({ data: { bulk_download_item_name: `item-${call}.zip` } }); + }); + + const result = await run(baseQuery, { image_names: names(2500) }); + + expect(baseQuery).toHaveBeenCalledTimes(3); + // Only the first name is returned; each background task announces its own zip over the + // socket, so the payload is just a handle for the single "preparing" toast. + expect(result).toEqual({ data: { bulk_download_item_name: 'item-1.zip' } }); + expect(toast).not.toHaveBeenCalled(); + }); + + it('sends one request for a board download, which the server expands itself', async () => { + const baseQuery = vi.fn( + (_args: Request): Promise => Promise.resolve({ data: { bulk_download_item_name: 'board.zip' } }) + ); + + await run(baseQuery, { image_names: [], board_id: 'board-1' }); + + expect(baseQuery).toHaveBeenCalledTimes(1); + expect(baseQuery.mock.calls[0]?.[0].body.board_id).toBe('board-1'); + }); + + it('resolves a mid-run failure instead of rejecting, since the earlier zips still arrive', async () => { + // The route answers 202 as soon as it has scheduled the background task, so chunk 1's zip + // is already being built. Rejecting would drive `matchRejected` -> "problem preparing + // download" while that zip lands in the user's downloads anyway. + let call = 0; + const baseQuery = vi.fn((_args: Request): Promise => { + call += 1; + if (call === 2) { + return Promise.resolve({ error: { status: 403, data: 'nope' } }); + } + return Promise.resolve({ data: { bulk_download_item_name: `item-${call}.zip` } }); + }); + + const result = await run(baseQuery, { image_names: names(2500) }); + + expect(result).toEqual({ data: { bulk_download_item_name: 'item-1.zip' } }); + expect(baseQuery).toHaveBeenCalledTimes(2); // stopped, did not keep firing chunks + // The 1500 names from the failing chunk on are in no zip at all -- reported here, because + // ImagesDownloaded carries no per-name failure list to fold them into. + expect(toast).toHaveBeenCalledTimes(1); + expect(vi.mocked(toast).mock.calls[0]?.[0]).toMatchObject({ + id: 'IMAGES_FAILED_TO_DOWNLOAD', + status: 'warning', + }); + // Asserted on the interpolation rather than the title, since i18n is mocked to echo the + // key. The count is the whole point of the toast: the failing chunk's 1000 names plus the + // 500 never sent. Off by one chunk in either direction and the user is told the wrong + // number of images are missing from their download. + expect(i18n.t).toHaveBeenCalledWith('toast.imagesFailedToDownload', { count: 1500 }); + }); + + it('still schedules-and-reports when the 202 body does not survive the trip back', async () => { + // `fetchBaseQuery` resolves an empty entity as `data: null`, so a proxy that strips the + // body off the 202 leaves nothing to return -- but the background task was scheduled all + // the same and its zip will arrive. Any nullish test over the payload (`!first`) treats + // that as nothing-happened and toasts an error over an arriving download. + let call = 0; + const baseQuery = vi.fn((_args: Request): Promise => { + call += 1; + if (call === 2) { + return Promise.resolve({ error: { status: 500, data: 'boom' } }); + } + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + return Promise.resolve({ data: null as any }); + }); + + const result = await run(baseQuery, { image_names: names(2500) }); + + expect(result).toEqual({ data: null }); + expect(i18n.t).toHaveBeenCalledWith('toast.imagesFailedToDownload', { count: 1500 }); + }); + + it('reports an error when the first chunk fails, since nothing was scheduled', async () => { + const baseQuery = vi.fn( + (_args: Request): Promise => Promise.resolve({ error: { status: 403, data: 'nope' } }) + ); + + const result = await run(baseQuery, { image_names: names(2500) }); + + expect(result).toEqual({ error: { status: 403, data: 'nope' } }); + expect(baseQuery).toHaveBeenCalledTimes(1); + // Nothing landed, so the failure toast belongs to the `matchRejected` listener alone. + expect(toast).not.toHaveBeenCalled(); + }); +}); diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index 502b3e89724..967aee3132c 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -212,6 +212,86 @@ const toastFailedImages = (count: number) => { } }; +/** + * The download counterpart. Distinct id and wording: "could not be updated" is wrong for a + * download, and sharing the id would let one warning overwrite the other, since the toast + * system updates in place. + */ +const toastFailedDownloads = (count: number) => { + if (count > 0) { + toast({ + id: 'IMAGES_FAILED_TO_DOWNLOAD', + title: i18n.t('toast.imagesFailedToDownload', { count }), + status: 'warning', + }); + } +}; + +/** + * Runs `/images/download` one conforming chunk at a time. + * + * Chunked like the mutating batch routes, but it cannot merge: each request produces its own + * bulk-download item, so an oversized selection becomes several zips rather than one. That is + * the cost of keeping the selection downloadable at all — the alternative is a 422 the moment + * the user hits select-all on a board past the cap. + * + * Only the first item name is returned, and only to give `matchFulfilled` a name for the single + * "preparing" toast. The zips themselves arrive independently: each background task emits its + * own `bulk_download_complete`, and the socket handler fetches and saves per event, keyed on the + * item name in the event rather than on this payload. + * + * A mid-run failure therefore cannot be reported as a plain rejection. The route answers 202 the + * moment it has scheduled the background task, so every chunk before the failing one is already + * producing a zip that will land in the user's downloads. Rejecting drives `matchRejected` and + * toasts "problem preparing download" while those zips arrive anyway — the opposite of what + * happened. So, exactly as in `buildChunkedImageBatchQueryFn`, only a run where *nothing* was + * scheduled surfaces as an error; a partial run resolves and reports the names that never made + * it into any zip. + */ +export const bulkDownloadQueryFn = async ( + { image_names, board_id }: components['schemas']['Body_download_images_from_list'], + _api: unknown, + _extraOptions: unknown, + baseQuery: ImagesBaseQuery +) => { + // A board download expands server-side from board_id alone, so there is nothing to split. + // Note the server picks board_id over image_names when both are set (`BulkDownloadService`), + // so a body carrying both is not a selection this can meaningfully chunk — no caller sends + // one, and splitting it would ask for the same full-board zip once per chunk. + const chunks = image_names?.length ? chunkImageNames(image_names) : [image_names ?? []]; + // Tracked separately from the payload rather than inferred from it. `fetchBaseQuery` resolves + // an empty response entity as `data: null`, so a 202 whose body did not survive the trip back + // leaves nothing to return even though the background task WAS scheduled and its zip is + // already being built. Any nullish test over the payload then sends us down the + // nothing-happened path and toasts a failure over an arriving download. + let scheduled = false; + let first: components['schemas']['ImagesDownloaded'] | undefined; + for (const [index, chunk] of chunks.entries()) { + const response = await baseQuery({ + url: buildImagesUrl('download'), + method: 'POST', + body: { image_names: chunk, board_id }, + }); + if (response.error) { + if (!scheduled) { + // Nothing was scheduled, so this is an ordinary failed request — report it as one and + // let the `matchRejected` listener raise the failure toast. + return { error: response.error }; + } + // Everything from this chunk on is unreached: those names are in no zip. The failing + // chunk counts too — it was reached, but nothing was scheduled for it. Toasted here + // rather than folded into the payload because `ImagesDownloaded` has no per-name failure + // list, and unlike the mutating routes this endpoint has no `onQueryStarted` that would + // make this a second reporting site for the same toast id. + toastFailedDownloads(chunks.slice(index).flat().length); + return { data: first }; + } + scheduled = true; + first ??= response.data as components['schemas']['ImagesDownloaded']; + } + return { data: first as components['schemas']['ImagesDownloaded'] }; +}; + export const imagesApi = api.injectEndpoints({ endpoints: (build) => ({ /** @@ -602,34 +682,7 @@ export const imagesApi = api.injectEndpoints({ components['schemas']['ImagesDownloaded'], components['schemas']['Body_download_images_from_list'] >({ - /** - * Chunked like the mutating batch routes, but it cannot merge: each request produces its - * own bulk-download item, so an oversized selection becomes several zips rather than one. - * That is the cost of keeping the selection downloadable at all — the alternative is a 422 - * the moment the user hits select-all on a board past the cap. - * - * Only the first item name is returned, and only to give `matchFulfilled` a name for the - * single "preparing" toast. The zips themselves arrive independently: each background task - * emits its own `bulk_download_complete`, and the socket handler fetches and saves per - * event, keyed on the item name in the event rather than on this payload. - */ - queryFn: async ({ image_names, board_id }, _api, _extraOptions, baseQuery) => { - // A board download expands server-side from board_id alone, so there is nothing to split. - const chunks = image_names?.length ? chunkImageNames(image_names) : [image_names ?? []]; - let first: components['schemas']['ImagesDownloaded'] | undefined; - for (const chunk of chunks) { - const response = await baseQuery({ - url: buildImagesUrl('download'), - method: 'POST', - body: { image_names: chunk, board_id }, - }); - if (response.error) { - return { error: response.error }; - } - first ??= response.data as components['schemas']['ImagesDownloaded']; - } - return { data: first as components['schemas']['ImagesDownloaded'] }; - }, + queryFn: bulkDownloadQueryFn, }), /** * Get ordered list of image names for selection operations From d876b3e0fdb2a1ed709002ee881ba29f917d1603 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Wed, 19 Aug 2026 23:10:39 -0400 Subject: [PATCH 08/34] fix(api): decide board write access per name, and report what it cannot decide The batch remove route memoized the write-access decision for the whole request. A board flipped from Public to Private mid-batch therefore kept accepting removals from a contributor whose permission had just been revoked, for the rest of the names. The add route had the same window in a different shape: one check on the target board before a 1000-name loop. Both now decide for every name. That is only affordable because the decision reads the board record rather than its DTO -- ownership and visibility are two columns, while boards.get_dto() also resolves a cover image and runs three COUNT aggregates over the board's contents. Doing so exposed two more problems in the same path: - SqliteBoardRecordStorage.get translated every sqlite3.Error into BoardRecordNotFoundException, the same exception a board that does not exist raises. A decision taken once per request could only turn that into a visible 404; taken per name it silently dropped names out of the response -- absent from added/removed, absent from failed_images, no toast -- and the client went on showing them as moved. The translation is gone, as it already is for image records, and a name whose decision could not be taken is now reported rather than skipped. - remove_image_from_board deleted by image_name alone, so the decision followed the image rather than the board it was taken about: authorize against a public board, have the image moved to a private one in between, and the delete lands on the private board. The predicate is now on the write. Also drops an unreachable ImageRecordNotFoundException clause on the add path -- nothing in that block raises it, and the deleted-mid-batch case it was meant for arrives as a foreign-key error, which the record probe below already classifies. Co-Authored-By: Claude Opus 5 (1M context) --- invokeai/app/api/routers/board_images.py | 95 ++++-- .../board_image_records_base.py | 7 +- .../board_image_records_sqlite.py | 9 +- .../board_images/board_images_base.py | 3 +- .../board_images/board_images_default.py | 3 +- .../board_records/board_records_sqlite.py | 29 +- .../routers/test_board_images_maintenance.py | 23 +- .../routers/test_multiuser_authorization.py | 318 +++++++++++++++++- .../services/boards/test_boards_default.py | 35 ++ 9 files changed, 469 insertions(+), 53 deletions(-) diff --git a/invokeai/app/api/routers/board_images.py b/invokeai/app/api/routers/board_images.py index 6422d416377..1c6aa1cfc14 100644 --- a/invokeai/app/api/routers/board_images.py +++ b/invokeai/app/api/routers/board_images.py @@ -5,6 +5,7 @@ from invokeai.app.api.dependencies import ApiDependencies from invokeai.app.api.routers.image_move_maintenance import assert_image_move_maintenance_inactive from invokeai.app.api.routers.images import MAX_IMAGE_BATCH_SIZE, ImageName +from invokeai.app.services.board_records.board_records_common import BoardRecordNotFoundException from invokeai.app.services.image_records.image_records_common import ImageRecordNotFoundException from invokeai.app.services.images.images_common import AddImagesToBoardResult, RemoveImagesFromBoardResult @@ -18,13 +19,26 @@ def _assert_board_write_access(board_id: str, current_user: CurrentUserOrDefault - The user is an admin. - The user owns the board. - The board visibility is Public (public boards accept contributions from any user). + + Reads the board *record*, not its DTO. The decision needs only the owner and the + visibility, while BoardService.get_dto also resolves the cover image and runs three COUNT + aggregates over the board's contents — six queries to answer a question two columns settle. + That cost is the only reason a batch route would be tempted to decide once and reuse the + answer for every name, and reusing it is what lets a permission revoked mid-batch keep + working until the request ends. One indexed SELECT per name is cheap enough to re-decide. + (These routes are sync `def`, so the queries occupy a threadpool worker rather than the + event loop — but a 1000-name batch still holds one for six thousand round trips.) """ from invokeai.app.services.board_records.board_records_common import BoardVisibility try: - board = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id) - except Exception: + board = ApiDependencies.invoker.services.board_records.get(board_id) + except BoardRecordNotFoundException: raise HTTPException(status_code=404, detail="Board not found") + # Anything else — a locked or unreadable database — propagates. Catching it here would + # answer "no such board", which the batch loops below treat as a name to skip: a disk error + # would then drop names out of the response entirely, reported neither as moved nor as + # failed, and the client would show the move as done until the next refresh. if current_user.is_admin: return if board.user_id == current_user.user_id: @@ -34,6 +48,22 @@ def _assert_board_write_access(board_id: str, current_user: CurrentUserOrDefault raise HTTPException(status_code=403, detail="Not authorized to modify this board") +def _image_record_exists(image_name: str) -> bool: + """True if the image record is still present, False if it has been deleted. + + A storage error answers True: only a record positively known to be gone may be downgraded + from a reported failure to a silent skip. `ImageRecordStorage.get` no longer translates + sqlite errors into not-found, so the two cases are distinguishable here. + """ + try: + ApiDependencies.invoker.services.image_records.get(image_name) + return True + except ImageRecordNotFoundException: + return False + except Exception: + return True + + def _assert_image_direct_owner(image_name: str, current_user: CurrentUserOrDefault) -> None: """Raise 403 if the current user is not the direct owner of the image. @@ -109,7 +139,9 @@ def remove_image_from_board( assert_image_move_maintenance_inactive() removed_images: set[str] = set() affected_boards: set[str] = set() - ApiDependencies.invoker.services.board_images.remove_image_from_board(image_name=image_name) + ApiDependencies.invoker.services.board_images.remove_image_from_board( + image_name=image_name, board_id=old_board_id + ) removed_images.add(image_name) affected_boards.add("none") affected_boards.add(old_board_id) @@ -164,6 +196,11 @@ def add_images_to_board( # and could land in both added_images and failed_images. for image_name in dict.fromkeys(image_names): try: + # Re-decided per name rather than resting on the check above. Write access to + # the target board can be revoked while the batch is running — a board flipped + # from Public to Private — and a decision taken once at the top of a 1000-name + # request would let a contributor keep writing to it for the rest of the batch. + _assert_board_write_access(board_id, current_user) _assert_image_direct_owner(image_name, current_user) old_board_id = ( ApiDependencies.invoker.services.board_image_records.get_board_for_image(image_name) or "none" @@ -182,6 +219,14 @@ def add_images_to_board( # A genuine storage failure, not an auth/404 skip: it used to be swallowed by # `pass`, so the client counted the image as moved and the move silently # reverted on reload. + # + # Except that a name deleted between the ownership check and the insert lands + # here too, and not as something recognizable: board_images.image_name is a + # foreign key onto images.image_name, so the INSERT fails with a bare + # sqlite3.IntegrityError. Nothing in the exception says "gone", so the record + # is probed instead — only on this path, so the happy path pays nothing. + if not _image_record_exists(image_name): + continue failed_images.add(image_name) return AddImagesToBoardResult( added_images=list(added_images), @@ -231,21 +276,6 @@ def remove_images_from_board( removed_images: set[str] = set() failed_images: set[str] = set() affected_boards: set[str] = set() - # Decided once per board rather than once per name. The skip removed the early abort - # that used to cap an unauthorized batch at one check, and _assert_board_write_access - # goes through boards.get_dto() -- six queries including three COUNT aggregates over - # the board's contents. Unmemoized, a 1000-name batch on one board is 6000 synchronous - # queries on the event loop, which is exactly what MAX_IMAGE_BATCH_SIZE exists to stop. - board_is_writable: dict[str, bool] = {} - - def _may_write(board_id: str) -> bool: - if board_id not in board_is_writable: - try: - _assert_board_write_access(board_id, current_user) - board_is_writable[board_id] = True - except HTTPException: - board_is_writable[board_id] = False - return board_is_writable[board_id] # Dedup while preserving order — a repeated name would otherwise be processed twice # and could land in both removed_images and failed_images. @@ -264,14 +294,31 @@ def _may_write(board_id: str) -> bool: failed_images.add(image_name) continue - # The one authorization decision, outside the try below so that the only way to - # skip a name for auth is this branch. Folding it into the try would leave two - # paths to the same outcome, and neither would be individually load-bearing. - if old_board_id != "none" and not _may_write(old_board_id): - continue + # The one authorization decision, and it is taken fresh for every name. Memoizing + # it per board is tempting — the skip removed the early abort that used to cap an + # unauthorized batch at one check — but a cached True outlives the permission it + # recorded: flip a board from Public to Private mid-batch and the rest of the names + # are removed on an answer that is no longer true. _assert_board_write_access reads + # the board record (one indexed SELECT), so re-deciding costs about what the + # get_dto() it replaced cost once. + # + # Kept out of the try below so that the only way to skip a name for auth is this + # branch. Note the two failure modes are not the same: "not allowed" is a skip, + # while a storage error means the decision could not be taken at all, and a name we + # could not decide about must be reported rather than silently dropped. + if old_board_id != "none": + try: + _assert_board_write_access(old_board_id, current_user) + except HTTPException: + continue + except Exception: + failed_images.add(image_name) + continue try: - ApiDependencies.invoker.services.board_images.remove_image_from_board(image_name=image_name) + ApiDependencies.invoker.services.board_images.remove_image_from_board( + image_name=image_name, board_id=old_board_id + ) removed_images.add(image_name) affected_boards.add("none") affected_boards.add(old_board_id) diff --git a/invokeai/app/services/board_image_records/board_image_records_base.py b/invokeai/app/services/board_image_records/board_image_records_base.py index 561eb79ce5f..34df1986363 100644 --- a/invokeai/app/services/board_image_records/board_image_records_base.py +++ b/invokeai/app/services/board_image_records/board_image_records_base.py @@ -20,8 +20,13 @@ def add_image_to_board( def remove_image_from_board( self, image_name: str, + board_id: str, ) -> None: - """Removes an image from a board.""" + """Removes an image from the given board. + + Scoped to a board on purpose: callers authorize against the board the image is on, and + that decision must not be applied to a different board if the image moves in between. + """ pass @abstractmethod diff --git a/invokeai/app/services/board_image_records/board_image_records_sqlite.py b/invokeai/app/services/board_image_records/board_image_records_sqlite.py index 108123c2ca1..92d84305e01 100644 --- a/invokeai/app/services/board_image_records/board_image_records_sqlite.py +++ b/invokeai/app/services/board_image_records/board_image_records_sqlite.py @@ -36,14 +36,19 @@ def add_image_to_board( def remove_image_from_board( self, image_name: str, + board_id: str, ) -> None: with self._db.transaction() as cursor: + # Scoped to the board the caller was authorized against, not just the image. The + # routes read the image's board, authorize against *that* board, and only then + # remove; an unscoped DELETE would follow the image if it were moved in between, + # applying a decision taken about one board to a different one. cursor.execute( """--sql DELETE FROM board_images - WHERE image_name = ?; + WHERE image_name = ? AND board_id = ?; """, - (image_name,), + (image_name, board_id), ) def get_images_for_board( diff --git a/invokeai/app/services/board_images/board_images_base.py b/invokeai/app/services/board_images/board_images_base.py index 269cebfeaea..53c6010c02a 100644 --- a/invokeai/app/services/board_images/board_images_base.py +++ b/invokeai/app/services/board_images/board_images_base.py @@ -20,8 +20,9 @@ def add_image_to_board( def remove_image_from_board( self, image_name: str, + board_id: str, ) -> None: - """Removes an image from a board.""" + """Removes an image from the given board.""" pass @abstractmethod diff --git a/invokeai/app/services/board_images/board_images_default.py b/invokeai/app/services/board_images/board_images_default.py index b42ae3db031..61d2b4269ee 100644 --- a/invokeai/app/services/board_images/board_images_default.py +++ b/invokeai/app/services/board_images/board_images_default.py @@ -21,8 +21,9 @@ def add_image_to_board( def remove_image_from_board( self, image_name: str, + board_id: str, ) -> None: - self.__invoker.services.board_image_records.remove_image_from_board(image_name) + self.__invoker.services.board_image_records.remove_image_from_board(image_name, board_id) def get_all_board_image_names_for_board( self, diff --git a/invokeai/app/services/board_records/board_records_sqlite.py b/invokeai/app/services/board_records/board_records_sqlite.py index 1e3e11c8a36..b42852d6a29 100644 --- a/invokeai/app/services/board_records/board_records_sqlite.py +++ b/invokeai/app/services/board_records/board_records_sqlite.py @@ -58,20 +58,23 @@ def get( self, board_id: str, ) -> BoardRecord: + # A sqlite3.Error is deliberately NOT translated into BoardRecordNotFoundException. + # Translating it made the exception mean "no such board, OR the database is + # locked/corrupt/unreadable", and callers cannot tell those apart: the batch routes + # decide board write access off this read once per name, and a not-found answer there is + # a benign skip. A disk error would therefore drop names out of the response silently, + # reported neither as moved nor as failed. Mirrors ImageRecordStorage.get. with self._db.transaction() as cursor: - try: - cursor.execute( - """--sql - SELECT * - FROM boards - WHERE board_id = ?; - """, - (board_id,), - ) - - result = cast(Union[sqlite3.Row, None], cursor.fetchone()) - except sqlite3.Error as e: - raise BoardRecordNotFoundException from e + cursor.execute( + """--sql + SELECT * + FROM boards + WHERE board_id = ?; + """, + (board_id,), + ) + + result = cast(Union[sqlite3.Row, None], cursor.fetchone()) if result is None: raise BoardRecordNotFoundException return BoardRecord(**dict(result)) diff --git a/tests/app/routers/test_board_images_maintenance.py b/tests/app/routers/test_board_images_maintenance.py index 90c7df5aa47..c7e20fccdbd 100644 --- a/tests/app/routers/test_board_images_maintenance.py +++ b/tests/app/routers/test_board_images_maintenance.py @@ -7,7 +7,7 @@ from invokeai.app.api.dependencies import ApiDependencies from invokeai.app.api_app import app from invokeai.app.services.auth.token_service import TokenData -from invokeai.app.services.board_records.board_records_common import BoardVisibility +from invokeai.app.services.board_records.board_records_common import BoardRecord, BoardVisibility from invokeai.app.services.boards.boards_common import BoardDTO from invokeai.app.services.invoker import Invoker @@ -19,6 +19,25 @@ def __init__(self, invoker) -> None: self.invoker = invoker +def _board_record() -> BoardRecord: + """The record behind the DTO below. + + Board write access is decided off the record, not the DTO: ownership and visibility are + two columns, while boards.get_dto() also resolves a cover image and runs three COUNT + aggregates, and that cost per name is what the batch routes cannot afford. + """ + return BoardRecord( + board_id="board-id", + board_name="Board", + user_id="system", + created_at="2024-01-01 00:00:00.000", + updated_at="2024-01-01 00:00:00.000", + archived=False, + board_visibility=BoardVisibility.Private, + cover_image_name=None, + ) + + @pytest.fixture def client() -> TestClient: return TestClient(app) @@ -64,6 +83,7 @@ def test_board_image_mutations_are_blocked_during_image_move_maintenance( ) ), ) + monkeypatch.setattr(mock_invoker.services.board_records, "get", MagicMock(return_value=_board_record())) monkeypatch.setattr("invokeai.app.api.routers.board_images.ApiDependencies", mock_deps) monkeypatch.setattr("invokeai.app.api.routers._access.ApiDependencies", mock_deps) monkeypatch.setattr("invokeai.app.api.routers.image_move_maintenance.ApiDependencies", mock_deps) @@ -102,6 +122,7 @@ def test_board_image_mutation_checks_access_before_image_move_maintenance( ) ), ) + monkeypatch.setattr(mock_invoker.services.board_records, "get", MagicMock(return_value=_board_record())) monkeypatch.setattr("invokeai.app.api.routers.board_images.ApiDependencies", mock_deps) monkeypatch.setattr("invokeai.app.api.routers._access.ApiDependencies", mock_deps) monkeypatch.setattr("invokeai.app.api.routers.image_move_maintenance.ApiDependencies", mock_deps) diff --git a/tests/app/routers/test_multiuser_authorization.py b/tests/app/routers/test_multiuser_authorization.py index b552b20fb4b..46ce8500f56 100644 --- a/tests/app/routers/test_multiuser_authorization.py +++ b/tests/app/routers/test_multiuser_authorization.py @@ -510,25 +510,32 @@ def test_batch_remove_keeps_partial_successes_when_one_name_is_foreign( for call in mock_invoker.services.board_images.remove_image_from_board.call_args_list ] == ["own-rm-mixed"] - def test_batch_remove_checks_each_board_once( + def test_batch_remove_decides_board_write_access_once_per_name_and_cheaply( self, client: TestClient, mock_invoker: Invoker, monkeypatch: Any, user1_token: str, user2_token: str ): - """Skipping removed the early abort, so the per-board check must be memoized. + """Two invariants that pull against each other, asserted together. - Without it an unauthorized batch pays boards.get_dto() -- six queries, three of them - COUNT aggregates over the board -- once per name, synchronously, on the event loop. + Skipping removed the early abort that used to cap an unauthorized batch at one check, so + the decision is now taken for every name -- caching it per board would let a permission + revoked mid-batch keep working until the request ends (see the revocation test below). + + Which is only affordable because the decision reads the board *record*: one indexed + SELECT. Through boards.get_dto() it would be six queries -- three of them COUNT + aggregates over the board's contents -- per name, synchronously, on the event loop, for + a 1000-name batch. So get_dto must not appear in this path at all. """ user1 = mock_invoker.services.users.get_by_email("user1@test.com") assert user1 is not None - board_id = _create_board(client, user1_token, "User1 Board Memoized") - names = [f"victim-memo-{index}" for index in range(5)] + board_id = _create_board(client, user1_token, "User1 Board Perms") + names = [f"victim-perm-{index}" for index in range(5)] for name in names: _save_image(mock_invoker, name, user1.user_id) mock_invoker.services.board_image_records.add_image_to_board(board_id, name) - real_get_dto = mock_invoker.services.boards.get_dto - spy = MagicMock(side_effect=real_get_dto) - monkeypatch.setattr(mock_invoker.services.boards, "get_dto", spy) + get_dto_spy = MagicMock(side_effect=mock_invoker.services.boards.get_dto) + monkeypatch.setattr(mock_invoker.services.boards, "get_dto", get_dto_spy) + record_spy = MagicMock(side_effect=mock_invoker.services.board_records.get) + monkeypatch.setattr(mock_invoker.services.board_records, "get", record_spy) r = client.post( "/api/v1/board_images/batch/delete", @@ -537,7 +544,298 @@ def test_batch_remove_checks_each_board_once( ) assert r.status_code == status.HTTP_201_CREATED assert r.json()["removed_images"] == [] - assert spy.call_count == 1 + assert record_spy.call_count == len(names) + assert get_dto_spy.call_count == 0 + + def test_batch_remove_stops_when_board_write_access_is_revoked_mid_batch( + self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str + ): + """A contributor's write access ends the moment the board stops being public. + + Public boards accept contributions from anyone, so user2 may empty user1's public board. + Nothing about that decision holds for the rest of a 1000-name batch: user1 can make the + board private while it is still running. Caching the first `True` would remove every + remaining name on an answer that is no longer true, and none of it is undone. + """ + from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardVisibility + + user1 = mock_invoker.services.users.get_by_email("user1@test.com") + assert user1 is not None + board_id = _create_board(client, user1_token, "User1 Public Board Revoked") + _set_board_visibility(client, user1_token, board_id, "public") + names = [f"revoke-rm-{index}" for index in range(3)] + for name in names: + _save_image(mock_invoker, name, user1.user_id) + mock_invoker.services.board_image_records.add_image_to_board(board_id, name) + + def _revoke_after_first_removal(image_name: str, board_id: str) -> None: + mock_invoker.services.board_records.update(board_id, BoardChanges(board_visibility=BoardVisibility.Private)) + + mock_invoker.services.board_images.remove_image_from_board.reset_mock() + mock_invoker.services.board_images.remove_image_from_board.side_effect = _revoke_after_first_removal + + r = client.post( + "/api/v1/board_images/batch/delete", + json={"image_names": names}, + headers=_auth(user2_token), + ) + + assert r.status_code == status.HTTP_201_CREATED + body = r.json() + assert body["removed_images"] == [names[0]] + # The rest are an authorization skip, not a failure: absent from both lists. + assert body["failed_images"] == [] + assert mock_invoker.services.board_images.remove_image_from_board.call_count == 1 + + def test_batch_add_stops_when_board_write_access_is_revoked_mid_batch( + self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str + ): + """The same window on the add side, where the target board is checked before the loop.""" + from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardVisibility + + user2 = mock_invoker.services.users.get_by_email("user2@test.com") + assert user2 is not None + board_id = _create_board(client, user1_token, "User1 Public Board Add Revoked") + _set_board_visibility(client, user1_token, board_id, "public") + names = [f"revoke-add-{index}" for index in range(3)] + for name in names: + _save_image(mock_invoker, name, user2.user_id) + + def _revoke_after_first_add(board_id: str, image_name: str) -> None: + mock_invoker.services.board_records.update(board_id, BoardChanges(board_visibility=BoardVisibility.Private)) + + mock_invoker.services.board_images.add_image_to_board.reset_mock() + mock_invoker.services.board_images.add_image_to_board.side_effect = _revoke_after_first_add + + r = client.post( + "/api/v1/board_images/batch", + json={"board_id": board_id, "image_names": names}, + headers=_auth(user2_token), + ) + + assert r.status_code == status.HTTP_201_CREATED + body = r.json() + assert body["added_images"] == [names[0]] + assert body["failed_images"] == [] + assert mock_invoker.services.board_images.add_image_to_board.call_count == 1 + + def test_batch_add_skips_an_image_deleted_after_its_ownership_check( + self, client: TestClient, mock_invoker: Invoker, user2_token: str + ): + """A name deleted mid-batch is a skip, not a failure -- even here, where it arrives + as a bare foreign-key error. + + board_images.image_name references images.image_name, so an image deleted between the + ownership check and the insert fails the INSERT with sqlite3.IntegrityError. Nothing in + that exception says "gone", so without the record probe the name is reported (and + toasted) as a storage failure for an image the user themselves just deleted. + """ + user2 = mock_invoker.services.users.get_by_email("user2@test.com") + assert user2 is not None + names = ["fk-ok", "fk-vanished"] + for name in names: + _save_image(mock_invoker, name, user2.user_id) + board_id = _create_board(client, user2_token, "User2 FK Race Board") + + def _delete_then_insert(board_id: str, image_name: str) -> None: + if image_name == "fk-vanished": + mock_invoker.services.image_records.delete(image_name) + # The real storage, so the foreign key fires for real rather than being simulated. + mock_invoker.services.board_image_records.add_image_to_board(board_id, image_name) + + mock_invoker.services.board_images.add_image_to_board.reset_mock() + mock_invoker.services.board_images.add_image_to_board.side_effect = _delete_then_insert + + r = client.post( + "/api/v1/board_images/batch", + json={"board_id": board_id, "image_names": names}, + headers=_auth(user2_token), + ) + + assert r.status_code == status.HTTP_201_CREATED + body = r.json() + assert body["added_images"] == ["fk-ok"] + # Absent from both lists: not moved by us, and not a failure either. + assert body["failed_images"] == [] + + def test_batch_add_reports_a_failure_it_could_not_probe( + self, client: TestClient, mock_invoker: Invoker, monkeypatch: Any, user2_token: str + ): + """The probe's own failure path: an unreadable record must not become a skip. + + The insert failure is classified by asking whether the image record is still there, so + the probe decides whether a name is reported. If the probe cannot answer — the same + locked database that broke the insert — the only safe answer is "still there": a skip + claims the user's own concurrent delete caused this, and says nothing at all. + """ + import sqlite3 + + user2 = mock_invoker.services.users.get_by_email("user2@test.com") + assert user2 is not None + _save_image(mock_invoker, "unprobeable", user2.user_id) + board_id = _create_board(client, user2_token, "User2 Unprobeable Board") + + def _fail(board_id: str, image_name: str) -> None: + raise RuntimeError("storage is on fire") + + mock_invoker.services.board_images.add_image_to_board.reset_mock() + mock_invoker.services.board_images.add_image_to_board.side_effect = _fail + monkeypatch.setattr( + mock_invoker.services.image_records, + "get", + MagicMock(side_effect=sqlite3.OperationalError("database is locked")), + ) + + r = client.post( + "/api/v1/board_images/batch", + json={"board_id": board_id, "image_names": ["unprobeable"]}, + headers=_auth(user2_token), + ) + + assert r.status_code == status.HTTP_201_CREATED + body = r.json() + assert body["added_images"] == [] + assert body["failed_images"] == ["unprobeable"] + + @pytest.mark.parametrize("route", ["add", "remove"]) + def test_batch_routes_report_a_name_whose_board_check_hit_a_storage_error( + self, client: TestClient, mock_invoker: Invoker, monkeypatch: Any, user2_token: str, route: str + ): + """A name we could not decide about must be reported, never silently dropped. + + Board write access is now decided once per name, off board_records.get(). That read used + to answer a locked or unreadable database with BoardRecordNotFoundException — the same + exception a board that simply does not exist raises — which the routes turn into a 404 + and then skip. A disk error mid-batch would therefore drop names out of the response + entirely: absent from added/removed, absent from failed_images, no toast, and the client + re-rendering them as moved until the next refresh. That is exactly the outcome + failed_images exists to prevent, so the storage error has to stay distinguishable. + """ + import sqlite3 + + user2 = mock_invoker.services.users.get_by_email("user2@test.com") + assert user2 is not None + names = ["decide-ok", "decide-broken"] + for name in names: + _save_image(mock_invoker, name, user2.user_id) + board_id = _create_board(client, user2_token, "User2 Board Check Failure") + if route == "remove": + for name in names: + mock_invoker.services.board_image_records.add_image_to_board(board_id, name) + + real_get = mock_invoker.services.board_records.get + calls = {"n": 0} + # One decision per name, plus — on the add route only — the up-front check that answers + # a wholly unauthorized request with a 403 before the loop starts. Either way the last + # decision is the one taken for "decide-broken". + failing_call = 3 if route == "add" else 2 + + def _fail_on_the_last_decision(requested_board_id: str): + calls["n"] += 1 + if calls["n"] == failing_call: + raise sqlite3.OperationalError("database is locked") + return real_get(requested_board_id) + + monkeypatch.setattr(mock_invoker.services.board_records, "get", _fail_on_the_last_decision) + mock_invoker.services.board_images.add_image_to_board.reset_mock() + mock_invoker.services.board_images.remove_image_from_board.reset_mock() + + if route == "add": + r = client.post( + "/api/v1/board_images/batch", + json={"board_id": board_id, "image_names": names}, + headers=_auth(user2_token), + ) + moved_key = "added_images" + else: + r = client.post( + "/api/v1/board_images/batch/delete", + json={"image_names": names}, + headers=_auth(user2_token), + ) + moved_key = "removed_images" + + assert r.status_code == status.HTTP_201_CREATED + body = r.json() + assert body[moved_key] == ["decide-ok"] + assert body["failed_images"] == ["decide-broken"] + + def test_batch_remove_only_touches_the_board_it_authorized_against( + self, client: TestClient, mock_invoker: Invoker, monkeypatch: Any, user1_token: str, user2_token: str + ): + """Deciding freshly is not enough — the write has to be scoped to what was decided. + + The route reads the image's board, authorizes against *that* board, then removes. An + unscoped `DELETE ... WHERE image_name = ?` follows the image if it is moved in between, + so a decision taken about a public board could be applied to a private one: user2 is + authorized against public P, user1 moves the image to private Q, and the delete lands on + Q. The predicate belongs on the write. + """ + user1 = mock_invoker.services.users.get_by_email("user1@test.com") + assert user1 is not None + public_board = _create_board(client, user1_token, "User1 Public Source") + _set_board_visibility(client, user1_token, public_board, "public") + private_board = _create_board(client, user1_token, "User1 Private Destination") + _save_image(mock_invoker, "moving-target", user1.user_id) + mock_invoker.services.board_image_records.add_image_to_board(public_board, "moving-target") + + # The image moves to the private board between the route's board read and its delete. + real_get_dto = mock_invoker.services.images.get_dto + + def _move_after_reading(image_name: str): + dto = real_get_dto(image_name) + mock_invoker.services.board_image_records.add_image_to_board(private_board, image_name) + return dto + + monkeypatch.setattr(mock_invoker.services.images, "get_dto", _move_after_reading) + # The real storage, so the scoping is exercised rather than asserted on a mock. + mock_invoker.services.board_images.remove_image_from_board.side_effect = ( + mock_invoker.services.board_image_records.remove_image_from_board + ) + + r = client.post( + "/api/v1/board_images/batch/delete", + json={"image_names": ["moving-target"]}, + headers=_auth(user2_token), + ) + + assert r.status_code == status.HTTP_201_CREATED + # The image is still on the private board: user2 was never authorized against it. + assert mock_invoker.services.board_image_records.get_board_for_image("moving-target") == private_board + + def test_batch_add_still_reports_a_genuine_storage_failure( + self, client: TestClient, mock_invoker: Invoker, user2_token: str + ): + """The other half of the probe: a name whose record is still there stays a failure. + + Without this the skip above could be written as a blanket `continue` and nothing would + notice -- a move that silently reverted on reload is exactly what failed_images exists + to surface. + """ + user2 = mock_invoker.services.users.get_by_email("user2@test.com") + assert user2 is not None + names = ["storage-ok", "storage-broken"] + for name in names: + _save_image(mock_invoker, name, user2.user_id) + board_id = _create_board(client, user2_token, "User2 Storage Failure Board") + + def _fail_one(board_id: str, image_name: str) -> None: + if image_name == "storage-broken": + raise RuntimeError("storage is on fire") + + mock_invoker.services.board_images.add_image_to_board.reset_mock() + mock_invoker.services.board_images.add_image_to_board.side_effect = _fail_one + + r = client.post( + "/api/v1/board_images/batch", + json={"board_id": board_id, "image_names": names}, + headers=_auth(user2_token), + ) + + assert r.status_code == status.HTTP_201_CREATED + body = r.json() + assert body["added_images"] == ["storage-ok"] + assert body["failed_images"] == ["storage-broken"] # =========================================================================== diff --git a/tests/app/services/boards/test_boards_default.py b/tests/app/services/boards/test_boards_default.py index 6c13099a1fe..87d6fb9b959 100644 --- a/tests/app/services/boards/test_boards_default.py +++ b/tests/app/services/boards/test_boards_default.py @@ -122,3 +122,38 @@ def test_non_admin_board_listing_skips_owner_lookup(mock_invoker: Invoker) -> No assert [dto.owner_username for dto in result] == [None] mock_invoker.services.users.get.assert_not_called() # type: ignore[attr-defined] mock_invoker.services.users.get_many.assert_not_called() # type: ignore[attr-defined] + + +def test_board_records_get_does_not_disguise_a_storage_error_as_not_found() -> None: + """A sqlite3.Error out of the SELECT must stay a sqlite3.Error. + + Translating it made BoardRecordNotFoundException mean "no such board, OR the database is + unreadable", and the board-image batch routes cannot tell those apart: they decide write + access off this read once per name and treat not-found as a name to skip. A disk error would + then drop names out of the response silently — reported neither as moved nor as failed — + and the client would keep showing them as moved until the next refresh. Mirrors + test_image_records_get_does_not_disguise_a_storage_error_as_not_found. + """ + import sqlite3 + from contextlib import contextmanager + from typing import Any + + import pytest + + from invokeai.app.services.board_records.board_records_sqlite import SqliteBoardRecordStorage + + storage = SqliteBoardRecordStorage.__new__(SqliteBoardRecordStorage) + + class _Cursor: + def execute(self, *args: Any, **kwargs: Any) -> None: + raise sqlite3.OperationalError("database disk image is malformed") + + class _Db: + @contextmanager + def transaction(self): + yield _Cursor() + + storage._db = _Db() # pyright: ignore[reportAttributeAccessIssue] + + with pytest.raises(sqlite3.OperationalError): + storage.get("board-1") From c59e02b644ab96f7d8059f6b51adcb998920ebae Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Wed, 19 Aug 2026 23:10:52 -0400 Subject: [PATCH 09/34] fix(ui): keep a chunked batch inside the session that started it Requests read the bearer token out of localStorage when they are sent, so a selection split across several chunks carries no identity of its own. Log out and back in as someone else while one is running and the remaining chunks are applied as that user -- on a public board those writes land, committing half of one user's delete or board move under another's name, with nothing to roll it back. RTK Query's resetApiState does not help: it clears the store, not a queryFn that is already running. The session is captured before the first chunk and rechecked before each one. It is compared by the identity the token carries, not by the token and not by the auth generation counter. Not the bytes, because the sliding-window refresh mints a new token for the same login mid-batch. Not the counter, because beginAuthTransition bumps it when a login or logout request is *sent* -- before anything has changed, and whether or not it succeeds -- so a second tab visiting the login page would abort an unrelated batch in this one. The read path also rechecks after the response, before publishing: those DTOs were fetched as whoever was logged in when the chunk went out, and the store they would be written into may since have been reset for someone else. Two fixes to the download path while here: - A body carrying both board_id and image_names is a board download, not a selection to split. The server prefers board_id, so chunking the names scheduled the same full-board zip once per chunk. Normalized to the single request the server will honour. - A 202 whose body did not survive the trip back leaves no item name. The fulfilled listener dereferenced it, and reading it through optionals alone would be no better: the toast is persistent and is dismissed by name when the zip lands, so a keyless one gets a random id that the socket handler can never match, leaving a "preparing" banner up forever for a download that already arrived. With no name there is now no toast. This is also the case the mid-run return had been failing tsc over, which is why frontend-checks has been red. Co-Authored-By: Claude Opus 5 (1M context) --- .../listeners/bulkDownload.test.ts | 45 ++++ .../listeners/bulkDownload.tsx | 19 +- .../features/auth/store/authTokenRefresh.ts | 41 ++++ .../src/services/api/endpoints/images.test.ts | 227 +++++++++++++++++- .../web/src/services/api/endpoints/images.ts | 176 ++++++++++---- 5 files changed, 454 insertions(+), 54 deletions(-) create mode 100644 invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/bulkDownload.test.ts diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/bulkDownload.test.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/bulkDownload.test.ts new file mode 100644 index 00000000000..823b7a4c402 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/bulkDownload.test.ts @@ -0,0 +1,45 @@ +import { toast } from 'features/toast/toast'; +import { describe, expect, it, vi } from 'vitest'; + +import { addBulkDownloadListeners } from './bulkDownload'; + +vi.mock('features/toast/toast', () => ({ toast: vi.fn() })); +vi.mock('i18next', () => ({ t: vi.fn((key: string) => key) })); + +/** Collects the effects the listeners register, in registration order. */ +const collectEffects = () => { + const effects: ((action: { payload: unknown }) => void)[] = []; + const startAppListening = vi.fn(({ effect }: { effect: (action: { payload: unknown }) => void }) => { + effects.push(effect); + }); + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + addBulkDownloadListeners(startAppListening as any); + return effects; +}; + +describe('bulk download listeners', () => { + it('raises no toast for a fulfilled action with no payload', () => { + // `fetchBaseQuery` resolves an empty response entity as `data: null`, so a proxy that + // strips the body off the 202 fulfils this action with nothing in it. Dereferencing the + // payload would throw — but raising the toast anyway is no better: it is persistent + // (`duration: null`) and is dismissed by name when the zip lands, so without a name it + // gets a random id that the socket handler's close call can never match, and the banner + // stays on screen forever. The download is unaffected; its completion toast still arrives. + const [onFulfilled] = collectEffects(); + + expect(() => onFulfilled?.({ payload: null })).not.toThrow(); + expect(toast).not.toHaveBeenCalled(); + }); + + it('keys the preparing toast on the item name when there is one', () => { + // Distinct ids matter: the background task can finish in under 20ms, so the "ready" + // toast may already be on screen when this one is raised. + const [onFulfilled] = collectEffects(); + + onFulfilled?.({ payload: { bulk_download_item_name: 'item-1.zip', response: 'on its way' } }); + + expect(toast).toHaveBeenCalledWith( + expect.objectContaining({ id: 'preparing:item-1.zip', description: 'on its way' }) + ); + }); +}); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/bulkDownload.tsx b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/bulkDownload.tsx index fa4c29b8f42..1cfdc0dd7d4 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/bulkDownload.tsx +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/bulkDownload.tsx @@ -17,13 +17,26 @@ export const addBulkDownloadListeners = (startAppListening: AppStartListening) = // socket event. The background task can complete in under 20ms, so the // socket event may arrive *before* this Redux middleware runs — without // distinct IDs the "preparing" toast would overwrite the "ready" toast. - const itemName = action.payload.bulk_download_item_name; + // Read through optionals: the payload is typed as a model, but `fetchBaseQuery` resolves + // an empty response entity as `data: null`, so a proxy that strips the body off the 202 + // fulfils this action with nothing in it. + const itemName = action.payload?.bulk_download_item_name; + if (!itemName) { + // Nothing to key the toast on, so there must be no toast. This one is raised with + // `duration: null` and is dismissed by name when the zip arrives + // (`toastApi.close(\`preparing:${name}\`)` in setEventListeners); raising it without an + // id gets it a random one instead, which that close call can never match — a permanent + // "preparing your download" banner for a download that has already landed. The + // download itself is unaffected: it was scheduled server-side, and its completion + // toast arrives over the socket. + return; + } toast({ - id: itemName ? `preparing:${itemName}` : undefined, + id: `preparing:${itemName}`, title: t('gallery.bulkDownloadRequested'), status: 'success', // Show the response message if it exists, otherwise show the default message - description: action.payload.response || t('gallery.bulkDownloadRequestedDesc'), + description: action.payload?.response || t('gallery.bulkDownloadRequestedDesc'), duration: null, }); }, diff --git a/invokeai/frontend/web/src/features/auth/store/authTokenRefresh.ts b/invokeai/frontend/web/src/features/auth/store/authTokenRefresh.ts index ba23a73f9c8..8b1a7a84895 100644 --- a/invokeai/frontend/web/src/features/auth/store/authTokenRefresh.ts +++ b/invokeai/frontend/web/src/features/auth/store/authTokenRefresh.ts @@ -1,3 +1,5 @@ +import { tokensBelongToSameUser } from 'features/auth/store/authSlice'; + const AUTH_GENERATION_KEY = 'auth_generation'; const MEDIA_AUTH_LOCK = 'invokeai-media-auth'; const FALLBACK_LOCK_PREFIX = `${MEDIA_AUTH_LOCK}:`; @@ -43,6 +45,45 @@ export const beginAuthTransition = () => { export const shouldAcceptRefreshedToken = (requestToken: string, requestGeneration: number) => getAuthGeneration() === requestGeneration && localStorage.getItem('auth_token') === requestToken; +/** The session an operation started under. See `isSameAuthContext`. */ +export type AuthContext = { + token: string | null; +}; + +export const captureAuthContext = (): AuthContext => ({ + token: localStorage.getItem('auth_token'), +}); + +/** + * True while the session an operation began under is still the live one. + * + * Requests read the bearer token out of localStorage at send time (`dynamicBaseQuery`), so an + * operation that issues several of them does not carry one identity: log out and back in as + * someone else midway and the remaining requests go out as the new user. Nothing stops that on + * its own — RTK Query's `resetApiState` clears the store, not a `queryFn` that is already + * running. Callers that fan a single user action out into a sequence of requests must therefore + * capture the context up front and check it before each one. + * + * The comparison is on the identity the token carries, not on the token itself and not on the + * auth generation counter: + * - Not the bytes, because the sliding-window refresh mints a fresh token for the same login + * mid-operation, and aborting on that would abandon a batch for a routine event. + * - Not the generation counter, because `beginAuthTransition` bumps it when a login or logout + * *request is sent*, before anything has changed and regardless of whether it succeeds. A + * login in a second tab — even one that fails, or one that signs the same user back in — + * would abort an unrelated batch in this one. Identity answers the question the counter was + * standing in for, and answers it correctly: whoever the next chunk would be sent as is read + * fresh from localStorage every time. + * + * `sessionExpiredLogout` removes the token with no request at all, so the token comparison is + * what catches it. Two nulls compare equal, which keeps deployments that never issue a token — + * the single-user default — out of this entirely. + */ +export const isSameAuthContext = (context: AuthContext): boolean => { + const token = localStorage.getItem('auth_token'); + return token === context.token || tokensBelongToSameUser(context.token, token); +}; + const getFallbackLockTickets = (): FallbackLockTicket[] => { const tickets: FallbackLockTicket[] = []; const now = Date.now(); diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts index f3fdefb6add..073a7a61ac5 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts @@ -4,9 +4,12 @@ import { buildChunkedImageBatchQueryFn, bulkDownloadQueryFn, chunkImageNames, + imageDTOsByNamesQueryFn, + imagesApi, mergeImageBatchResults, } from 'services/api/endpoints/images'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ImageDTO } from 'services/api/types'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { api } from '..'; @@ -18,6 +21,42 @@ const CHUNK_SIZE = 1000; const names = (count: number) => Array.from({ length: count }, (_, i) => `image-${i}.png`); +/** + * The chunk loops read the live session out of localStorage before every request, so the tests + * need a real one to write to. Not available in the default (node) test environment. + */ +beforeAll(() => { + const values = new Map(); + vi.stubGlobal('localStorage', { + clear: () => values.clear(), + getItem: (key: string) => values.get(key) ?? null, + key: (index: number) => [...values.keys()][index] ?? null, + get length() { + return values.size; + }, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + }); +}); + +beforeEach(() => { + localStorage.clear(); +}); + +/** A token shaped like the real JWT: only the `user_id` claim is read back out of it. */ +const tokenFor = (userId: string, issuedAt = 1) => + `header.${btoa(JSON.stringify({ user_id: userId, iat: issuedAt }))}.signature`; + +const login = (userId: string, issuedAt = 1) => { + localStorage.setItem('auth_token', tokenFor(userId, issuedAt)); +}; + +/** What a logout + login as someone else leaves behind: a new token AND a bumped generation. */ +const switchUser = (userId: string) => { + localStorage.setItem('auth_generation', String(Number(localStorage.getItem('auth_generation') ?? 0) + 1)); + login(userId); +}; + describe('chunkImageNames', () => { it('leaves a conforming list as a single request', () => { expect(chunkImageNames(names(3))).toEqual([names(3)]); @@ -152,6 +191,86 @@ describe('buildChunkedImageBatchQueryFn', () => { expect(dispatch).not.toHaveBeenCalled(); expect(toast).not.toHaveBeenCalled(); }); + + it('stops when the session changes mid-run, instead of applying the rest as the new user', async () => { + // Every request picks up whatever token localStorage holds when it is sent, so a loop that + // outlives its own session finishes as whoever logged in next -- on a public board those + // writes land, committing half of one user's action under another's name. resetApiState on + // the logout action does not help: it clears the cache, not a queryFn that is running. + login('user-a'); + let call = 0; + const baseQuery = vi.fn((_args: Request): Promise => { + call += 1; + if (call === 2) { + switchUser('user-b'); + } + return Promise.resolve({ + data: { added_images: [`chunk-${call}.png`], failed_images: [], affected_boards: ['board-1'] }, + }); + }); + + const { result } = run(baseQuery, { image_names: names(2500) }); + + // Chunk 3 is never sent. Chunks 1 and 2 already committed, so the run is a partial success + // and its unreached names are reported, exactly as for a mid-run server failure. + expect(await result).toEqual({ + data: { + added_images: ['chunk-1.png', 'chunk-2.png'], + failed_images: names(2500).slice(2000), + affected_boards: ['board-1'], + }, + }); + expect(baseQuery).toHaveBeenCalledTimes(2); + }); + + it('keeps running when a login request elsewhere bumps the auth generation', async () => { + // `beginAuthTransition` bumps the shared counter when a login or logout request is *sent*, + // before anything has changed and whether or not it succeeds — a second tab opening the + // login page must not abandon this tab's batch. The session is judged by who the next + // request would go out as, which is unchanged here. + login('user-a'); + const baseQuery = vi.fn((_args: Request): Promise => { + localStorage.setItem('auth_generation', '7'); + return Promise.resolve({ data: { added_images: [], failed_images: [], affected_boards: [] } }); + }); + + const { result } = run(baseQuery, { image_names: names(2500) }); + await result; + + expect(baseQuery).toHaveBeenCalledTimes(3); + }); + + it('keeps running across a sliding-window token refresh, which is the same session', async () => { + // The middleware mints a fresh token on mutating requests. Comparing tokens byte-for-byte + // would abandon every batch long enough to be refreshed -- the exact operations chunking + // exists for. + login('user-a', 1); + const baseQuery = vi.fn((_args: Request): Promise => { + login('user-a', 2); + return Promise.resolve({ data: { added_images: [], failed_images: [], affected_boards: [] } }); + }); + + const { result } = run(baseQuery, { image_names: names(2500) }); + await result; + + expect(baseQuery).toHaveBeenCalledTimes(3); + }); + + it('stops when the session simply expires mid-run, which bumps no generation', async () => { + // sessionExpiredLogout drops the token with no request of its own -- a 401 anywhere, or a + // token that fails validation on load -- so the generation counter never moves. Without the + // token half of the check, the loop would run on with no credentials at all. + login('user-a'); + const baseQuery = vi.fn((_args: Request): Promise => { + localStorage.removeItem('auth_token'); + return Promise.resolve({ data: { added_images: [], failed_images: [], affected_boards: [] } }); + }); + + const { result } = run(baseQuery, { image_names: names(2500) }); + await result; + + expect(baseQuery).toHaveBeenCalledTimes(1); + }); }); describe('bulkDownloadQueryFn', () => { @@ -247,6 +366,37 @@ describe('bulkDownloadQueryFn', () => { expect(i18n.t).toHaveBeenCalledWith('toast.imagesFailedToDownload', { count: 1500 }); }); + it('sends one request when a board id rides along with an oversized name list', async () => { + // The server picks board_id over image_names (BulkDownloadService), so each chunk would + // schedule the same full-board zip again: 1001 names plus a board id used to produce two + // identical whole-board downloads. + const baseQuery = vi.fn( + (_args: Request): Promise => Promise.resolve({ data: { bulk_download_item_name: 'board.zip' } }) + ); + + await run(baseQuery, { image_names: names(2500), board_id: 'board-1' }); + + expect(baseQuery).toHaveBeenCalledTimes(1); + expect(baseQuery.mock.calls[0]?.[0].body).toEqual({ image_names: undefined, board_id: 'board-1' }); + }); + + it('stops scheduling zips when the session changes mid-run', async () => { + login('user-a'); + let call = 0; + const baseQuery = vi.fn((_args: Request): Promise => { + call += 1; + switchUser('user-b'); + return Promise.resolve({ data: { bulk_download_item_name: `item-${call}.zip` } }); + }); + + const result = await run(baseQuery, { image_names: names(2500) }); + + expect(baseQuery).toHaveBeenCalledTimes(1); + expect(result).toEqual({ data: { bulk_download_item_name: 'item-1.zip' } }); + // The 1500 names in the chunks that were never sent are in no zip. + expect(i18n.t).toHaveBeenCalledWith('toast.imagesFailedToDownload', { count: 1500 }); + }); + it('reports an error when the first chunk fails, since nothing was scheduled', async () => { const baseQuery = vi.fn( (_args: Request): Promise => Promise.resolve({ error: { status: 403, data: 'nope' } }) @@ -260,3 +410,78 @@ describe('bulkDownloadQueryFn', () => { expect(toast).not.toHaveBeenCalled(); }); }); + +describe('imageDTOsByNamesQueryFn', () => { + type Request = { url: string; method: string; body: { image_names: string[] } }; + type Response = { data: ImageDTO[] } | { error: { status: number; data: string } }; + + const dto = (image_name: string) => ({ image_name }) as ImageDTO; + + const run = (baseQuery: (args: Request) => Promise, image_names: string[]) => { + const dispatch = vi.fn(); + /* eslint-disable @typescript-eslint/no-explicit-any */ + const queryApi = { dispatch } as any; + const query = baseQuery as any; + /* eslint-enable @typescript-eslint/no-explicit-any */ + return { dispatch, result: imageDTOsByNamesQueryFn({ image_names }, queryApi, undefined, query) }; + }; + + it('publishes each chunk as it arrives, so a later failure cannot discard the earlier ones', async () => { + // This mutation rejects on any chunk failure and its only caller + // (useRangeBasedImageFetching) never looks at the rejection -- so DTOs held back until the + // end would be dropped for good. The hook re-requests only names missing from the cache, + // and only when the user scrolls, so nothing would come back for them. + let call = 0; + const baseQuery = vi.fn((args: Request): Promise => { + call += 1; + if (call === 2) { + return Promise.resolve({ error: { status: 500, data: 'boom' } }); + } + return Promise.resolve({ data: args.body.image_names.map(dto) }); + }); + + const { dispatch, result } = run(baseQuery, names(2500)); + + expect(await result).toEqual({ error: { status: 500, data: 'boom' } }); + // One dispatch, carrying chunk one -- not zero, which is what holding the DTOs back until + // the end would produce. Matched on the payload: the action also carries a request id and a + // timestamp, both fresh per call. + expect(dispatch).toHaveBeenCalledTimes(1); + const action = dispatch.mock.calls[0]?.[0] as { + type: string; + payload: { value: ImageDTO }[]; + }; + expect(action.type).toBe(imagesApi.util.upsertQueryEntries([]).type); + expect(action.payload.map((entry) => entry.value.image_name)).toEqual(names(1000)); + }); + + it('does not publish a chunk that came back after the session changed', async () => { + // The DTOs were fetched as whoever was logged in when the chunk went out. By the time they + // land the logout listener may have reset the api state for someone else, and writing them + // in then seeds one user's cache with another's images. + login('user-a'); + const baseQuery = vi.fn((args: Request): Promise => { + switchUser('user-b'); + return Promise.resolve({ data: args.body.image_names.map(dto) }); + }); + + const { dispatch, result } = run(baseQuery, names(2500)); + await result; + + expect(baseQuery).toHaveBeenCalledTimes(1); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('stops reading when the session changes mid-run', async () => { + login('user-a'); + const baseQuery = vi.fn((args: Request): Promise => { + switchUser('user-b'); + return Promise.resolve({ data: args.body.image_names.map(dto) }); + }); + + const { result } = run(baseQuery, names(2500)); + + expect(baseQuery).toHaveBeenCalledTimes(1); + expect(await result).toEqual({ error: { status: 'CUSTOM_ERROR', error: expect.stringContaining('Aborted') } }); + }); +}); diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index 967aee3132c..912e63c8258 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -1,7 +1,10 @@ +import type { Dispatch, UnknownAction } from '@reduxjs/toolkit'; import type { FetchArgs, FetchBaseQueryError, QueryReturnValue } from '@reduxjs/toolkit/query'; import { skipToken } from '@reduxjs/toolkit/query'; import { getStore } from 'app/store/nanostores/store'; import { uniq } from 'es-toolkit'; +import type { AuthContext } from 'features/auth/store/authTokenRefresh'; +import { captureAuthContext, isSameAuthContext } from 'features/auth/store/authTokenRefresh'; import type { CroppableImageWithDims } from 'features/controlLayers/store/types'; import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types'; import { toast } from 'features/toast/toast'; @@ -73,6 +76,41 @@ type ImagesBaseQuery = ( args: string | FetchArgs ) => QueryReturnValue | PromiseLike>; +/** + * Stands in for the response of a chunk that was never sent because the session changed under + * the operation. Shaped like any other chunk failure so it takes the partial-result path the + * loops already have: what the previous chunks committed is reported, the rest is unreached. + */ +const AUTH_CHANGED_ERROR: FetchBaseQueryError = { + status: 'CUSTOM_ERROR', + error: 'Aborted: the authenticated session changed while the operation was running', +}; + +/** + * Issues one chunk of a multi-request operation, unless the session it started under is gone. + * + * A selection past the batch cap is split into several requests, and each one picks up whatever + * bearer token localStorage holds when it is sent — the loop does not carry the identity it + * started with. Log out and back in as another user with the loop still running and the + * remaining chunks are applied as that user; on a public board they can land, so half of one + * user's delete is committed under another's name, with nothing to roll it back. Nothing else + * stops it: `resetApiState` on the logout action clears the cache, not a running `queryFn`. + * + * So the context is captured before the first chunk and rechecked before each one. See + * `isSameAuthContext` for what counts as the same session — notably a sliding-window token + * refresh does not, or every long batch would abandon itself. + */ +const fetchChunk = async ( + baseQuery: ImagesBaseQuery, + authContext: AuthContext, + args: FetchArgs +): Promise> => { + if (!isSameAuthContext(authContext)) { + return { error: AUTH_CHANGED_ERROR }; + } + return await baseQuery(args); +}; + export const chunkImageNames = (image_names: string[]): string[][] => { if (image_names.length <= IMAGE_BATCH_CHUNK_SIZE) { // Single-request path, byte-for-byte what it was before chunking existed. Note this also @@ -137,9 +175,10 @@ export const buildChunkedImageBatchQueryFn = baseQuery: ImagesBaseQuery ) => { const results: TResult[] = []; + const authContext = captureAuthContext(); const chunks = chunkImageNames(arg.image_names); for (const [index, image_names] of chunks.entries()) { - const response = await baseQuery({ ...request(arg), body: { ...arg, image_names } }); + const response = await fetchChunk(baseQuery, authContext, { ...request(arg), body: { ...arg, image_names } }); if (response.error) { if (results.length === 0) { // Nothing was applied, so this is an ordinary failed request — report it as one. @@ -198,6 +237,25 @@ const getRemoveImagesFromBoardTags = ( ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards), ]; +/** + * Publishes fetched DTOs into the individual `getImageDTO` caches, which is where every + * subscribed component reads from — this batch endpoint holds no cache of its own. + */ +const upsertImageDTOs = (dispatch: Dispatch, imageDTOs: ImageDTO[]) => { + if (imageDTOs.length === 0) { + return; + } + const updates: Param0 = []; + for (const imageDTO of imageDTOs) { + updates.push({ + endpointName: 'getImageDTO', + arg: imageDTO.image_name, + value: imageDTO, + }); + } + dispatch(imagesApi.util.upsertQueryEntries(updates)); +}; + /** * Surfaces the partial-failure warning. Used for both kinds of partial failure: the names the * server reported it could not apply, and the names a chunked run never reached. @@ -254,11 +312,15 @@ export const bulkDownloadQueryFn = async ( _extraOptions: unknown, baseQuery: ImagesBaseQuery ) => { - // A board download expands server-side from board_id alone, so there is nothing to split. - // Note the server picks board_id over image_names when both are set (`BulkDownloadService`), - // so a body carrying both is not a selection this can meaningfully chunk — no caller sends - // one, and splitting it would ask for the same full-board zip once per chunk. - const chunks = image_names?.length ? chunkImageNames(image_names) : [image_names ?? []]; + // A board download expands server-side from board_id alone, so there is nothing to split — + // and the server picks board_id over image_names when both are set (`BulkDownloadService`), + // which makes a body carrying both a board download too, not a selection to chunk. Splitting + // it would ask for the same full-board zip once per chunk: 1001 names alongside a board id + // would schedule two identical full-board jobs. Normalized here to the one request the server + // is actually going to honour, so the client cannot amplify a download by sending a field the + // server ignores. No UI caller sends both today; the endpoint accepts it. + const chunks: (string[] | undefined)[] = board_id ? [undefined] : chunkImageNames(image_names ?? []); + const authContext = captureAuthContext(); // Tracked separately from the payload rather than inferred from it. `fetchBaseQuery` resolves // an empty response entity as `data: null`, so a 202 whose body did not survive the trip back // leaves nothing to return even though the background task WAS scheduled and its zip is @@ -267,7 +329,7 @@ export const bulkDownloadQueryFn = async ( let scheduled = false; let first: components['schemas']['ImagesDownloaded'] | undefined; for (const [index, chunk] of chunks.entries()) { - const response = await baseQuery({ + const response = await fetchChunk(baseQuery, authContext, { url: buildImagesUrl('download'), method: 'POST', body: { image_names: chunk, board_id }, @@ -283,8 +345,12 @@ export const bulkDownloadQueryFn = async ( // rather than folded into the payload because `ImagesDownloaded` has no per-name failure // list, and unlike the mutating routes this endpoint has no `onQueryStarted` that would // make this a second reporting site for the same toast id. - toastFailedDownloads(chunks.slice(index).flat().length); - return { data: first }; + toastFailedDownloads(chunks.slice(index).flatMap((names) => names ?? []).length); + // Cast, like the return below: `first` is nullish only when a 202 came back with no body + // (see `scheduled`), and ImagesDownloaded has no way to say "scheduled, but the body did + // not survive the trip". The fulfilled-action listener reads the payload through + // optionals for exactly that reason. + return { data: first as components['schemas']['ImagesDownloaded'] }; } scheduled = true; first ??= response.data as components['schemas']['ImagesDownloaded']; @@ -292,6 +358,54 @@ export const bulkDownloadQueryFn = async ( return { data: first as components['schemas']['ImagesDownloaded'] }; }; +/** + * Fetches DTOs for a list of image names, one conforming chunk at a time. + * + * Chunked despite being a read: `useRangeBasedImageFetching` unions every virtuoso range seen + * inside its throttle window, and a dense grid with a 4096px overscan can carry that past the + * cap. The failure was silent — the hook swallows the rejection and nothing listens for it, so + * the affected thumbnails simply never loaded. + * + * Each chunk is published into the individual `getImageDTO` caches as it arrives, rather than + * all of them at the end. This mutation rejects on any chunk failure and its one caller never + * looks at the rejection, so a transient failure late in a wide range would otherwise throw + * away every thumbnail the earlier chunks had already fetched — and they would not be + * re-requested either, since the hook asks only for names it cannot find in the cache and the + * next request is driven by scrolling, not by the failure. + */ +export const imageDTOsByNamesQueryFn = async ( + { image_names }: components['schemas']['Body_get_images_by_names'], + // Typed as the plain store dispatch rather than against `imagesApi.util.upsertQueryEntries`: + // naming imagesApi in a signature the endpoint definitions themselves depend on makes its + // inferred type circular, and every consumer of the api silently degrades to `any`. + { dispatch }: { dispatch: Dispatch }, + _extraOptions: unknown, + baseQuery: ImagesBaseQuery +) => { + const authContext = captureAuthContext(); + const imageDTOs: ImageDTO[] = []; + for (const chunk of chunkImageNames(image_names)) { + const response = await fetchChunk(baseQuery, authContext, { + url: buildImagesUrl('images_by_names'), + method: 'POST', + body: { image_names: chunk }, + }); + if (response.error) { + return { error: response.error }; + } + const chunkDTOs = response.data as ImageDTO[]; + // Checked again after the response, not just before the request: these DTOs were fetched + // as whoever was logged in when the chunk went out, and the store they would be written + // into may since have been reset for someone else (the logout listener clears the api + // state). Publishing them then would seed one user's cache with another's images. + if (isSameAuthContext(authContext)) { + upsertImageDTOs(dispatch, chunkDTOs); + } + imageDTOs.push(...chunkDTOs); + } + return { data: imageDTOs }; +}; + export const imagesApi = api.injectEndpoints({ endpoints: (build) => ({ /** @@ -705,47 +819,9 @@ export const imagesApi = api.injectEndpoints({ paths['/api/v1/images/images_by_names']['post']['responses']['200']['content']['application/json'], paths['/api/v1/images/images_by_names']['post']['requestBody']['content']['application/json'] >({ - /** - * Chunked too, despite being a read: `useRangeBasedImageFetching` unions every virtuoso - * range seen inside its throttle window, and a dense grid with a 4096px overscan can carry - * that past the cap. The failure was silent — the hook swallows the rejection and nothing - * listens for it, so the affected thumbnails simply never loaded. Results concatenate - * because the route returns a plain ordered list, and the caller only upserts by name. - */ - queryFn: async ({ image_names }, _api, _extraOptions, baseQuery) => { - const imageDTOs: ImageDTO[] = []; - for (const chunk of chunkImageNames(image_names)) { - const response = await baseQuery({ - url: buildImagesUrl('images_by_names'), - method: 'POST', - body: { image_names: chunk }, - }); - if (response.error) { - return { error: response.error }; - } - imageDTOs.push(...(response.data as ImageDTO[])); - } - return { data: imageDTOs }; - }, - // Don't provide cache tags - we'll manually upsert into individual getImageDTO caches - async onQueryStarted(_, { dispatch, queryFulfilled }) { - try { - const { data: imageDTOs } = await queryFulfilled; - - // Upsert each DTO into the individual image cache - const updates: Param0 = []; - for (const imageDTO of imageDTOs) { - updates.push({ - endpointName: 'getImageDTO', - arg: imageDTO.image_name, - value: imageDTO, - }); - } - dispatch(imagesApi.util.upsertQueryEntries(updates)); - } catch { - // Handle error if needed - } - }, + queryFn: imageDTOsByNamesQueryFn, + // No cache tags: the DTOs are upserted into the individual getImageDTO caches by the + // queryFn, chunk by chunk, which is also all an onQueryStarted handler would have done. }), }), }); From c267faec21a424c40fe38f6579f4acfc63d116b5 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Wed, 19 Aug 2026 23:11:00 -0400 Subject: [PATCH 10/34] fix(ui): wait for the board move before clearing the change-board selection The image batch mutation was fired and forgotten. ConfirmationAlertDialog calls acceptCallback and then onClose without awaiting, so changeBoardReset ran while the request was still in flight and took its failed_images with it: the names that did not move were cleared along with the ones that did, leaving nothing to retry from. It is now awaited alongside the video promises, and the names the server could not move stay selected. A whole-request rejection retains the full selection, since nothing moved -- note that nothing toasts those rejections today, neither the endpoint's handler nor a matchRejected listener, which is unchanged here. Every one of those writes lands after an unbounded await, though, so each goes through canRetainFailedSelection first. By then the user may have reopened the dialog on a different selection -- overwriting it would move a set they never chose to the board they picked for something else -- or the session may have ended, and the logout listener clears this slice deliberately. The success-path reset is guarded too; it had the same exposure already. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/ChangeBoardModal.test.ts | 54 ++++++++++++++++ .../components/ChangeBoardModal.tsx | 62 +++++++++++++++---- .../features/changeBoardModal/store/slice.ts | 24 +++++++ 3 files changed, 127 insertions(+), 13 deletions(-) create mode 100644 invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.test.ts diff --git a/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.test.ts b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.test.ts new file mode 100644 index 00000000000..606d46b2e27 --- /dev/null +++ b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.test.ts @@ -0,0 +1,54 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +import { canRetainFailedSelection } from 'features/changeBoardModal/store/slice'; +import { describe, expect, it } from 'vitest'; + +describe('canRetainFailedSelection', () => { + const unclaimed = { isModalOpen: false, image_names: [], video_names: [] }; + + it('allows the write-back when nothing has claimed the modal since', () => { + expect(canRetainFailedSelection(unclaimed, true)).toBe(true); + }); + + it('refuses once another selection has claimed the modal', () => { + // Right-click a different image while a large move is in flight: the dialog is open again + // with that one name in it. Overwriting it with the earlier request's failures would move + // a set the user never chose, to the board they picked for something else. + expect(canRetainFailedSelection({ ...unclaimed, isModalOpen: true }, true)).toBe(false); + expect(canRetainFailedSelection({ ...unclaimed, image_names: ['other.png'] }, true)).toBe(false); + expect(canRetainFailedSelection({ ...unclaimed, video_names: ['other.mp4'] }, true)).toBe(false); + }); + + it('refuses once the session has ended', () => { + // The logout listener clears this slice along with the api state; re-seeding it afterwards + // leaves one user's image names in the next user's store. + expect(canRetainFailedSelection(unclaimed, false)).toBe(false); + }); +}); + +/** + * A source-level guard, in the manner of videoReviewRegressions: this repo does not do UI + * tests, and what is being guarded here is an ordering property rather than a value. Accepting + * the dialog resets the selection on the way out (ConfirmationAlertDialog calls acceptCallback + * and then onClose), so an image mutation that is fired and forgotten has its `failed_images` + * cleared away with everything else — the images that did not move leave no trace to retry from. + */ +describe('ChangeBoardModal', () => { + const source = readFileSync(fileURLToPath(new URL('./ChangeBoardModal.tsx', import.meta.url)), 'utf8'); + + it('awaits the image board mutation instead of firing and forgetting it', () => { + expect(source).toMatch(/addImagesToBoard\([\s\S]*?\.unwrap\(\)/); + expect(source).toContain('result.failed_images'); + expect(source).toMatch(/failedImageNames\.length === 0[\s\S]*changeBoardReset/); + // Every late write goes through the guard, the reset included — it can clear a selection + // that now belongs to someone else just as easily as the retain can overwrite one. + expect(source).toMatch(/canRetainFailedSelection\([\s\S]*?return;[\s\S]*changeBoardReset/); + }); + + it('keeps the images that did not move selected', () => { + expect(source).toContain('imagesToChangeSelected(failedImageNames)'); + // A rejected request moved nothing at all, so the whole request stays selected. + expect(source).toContain('.catch(() => imagesToChange)'); + }); +}); diff --git a/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx index d24e812fc4a..058c2b8a8d9 100644 --- a/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx +++ b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx @@ -1,11 +1,14 @@ import type { ComboboxOnChange, ComboboxOption } from '@invoke-ai/ui-library'; import { Combobox, ConfirmationAlertDialog, Flex, FormControl, Text } from '@invoke-ai/ui-library'; import { createSelector } from '@reduxjs/toolkit'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useAppDispatch, useAppSelector, useAppStore } from 'app/store/storeHooks'; import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; import { selectCurrentUser } from 'features/auth/store/authSlice'; +import { captureAuthContext, isSameAuthContext } from 'features/auth/store/authTokenRefresh'; import { + canRetainFailedSelection, changeBoardReset, + imagesToChangeSelected, isModalOpenChanged, selectChangeBoardModalSlice, videosToChangeSelected, @@ -37,6 +40,7 @@ const selectIsModalOpen = createSelector( const ChangeBoardModal = () => { useAssertSingleton('ChangeBoardModal'); const dispatch = useAppDispatch(); + const store = useAppStore(); const currentBoardId = useAppSelector(selectSelectedBoardId); const currentUser = useAppSelector(selectCurrentUser); const [selectedBoardId, setSelectedBoardId] = useState(); @@ -85,16 +89,27 @@ const ChangeBoardModal = () => { return; } - if (imagesToChange.length) { - if (selectedBoardId === 'none') { - removeImagesFromBoard({ image_names: imagesToChange }); - } else { - addImagesToBoard({ - image_names: imagesToChange, - board_id: selectedBoardId, - }); - } - } + const authContext = captureAuthContext(); + + // Awaited, not fired and forgotten. The batch routes report per-name failures in + // `failed_images`, and accepting this dialog resets the selection on the way out + // (ConfirmationAlertDialog calls acceptCallback then onClose) — so the names that did not + // move used to be dropped along with the ones that did, leaving nothing to retry from. + // + // Per-name failures are toasted by the endpoint itself (`onQueryStarted`), so what is left + // to do here is keep those names selected. A whole-request rejection is different: it means + // none of them moved, and nothing toasts those today — neither the endpoint's handler + // (documented no-op) nor any matchRejected listener, since only the single-image board + // routes have one. + const failedImageNamesPromise: Promise = !imagesToChange.length + ? Promise.resolve([]) + : (selectedBoardId === 'none' + ? removeImagesFromBoard({ image_names: imagesToChange }) + : addImagesToBoard({ image_names: imagesToChange, board_id: selectedBoardId }) + ) + .unwrap() + .then((result) => result.failed_images) + .catch(() => imagesToChange); const videoMutations: { videoName: string; promise: Promise }[] = []; if (videosToChange.length) { @@ -112,12 +127,32 @@ const ChangeBoardModal = () => { } } - const results = await Promise.allSettled(videoMutations.map(({ promise }) => promise)); + // Both kinds go out together: the video routes take one name at a time, and serializing + // them behind the image batch would leave a large move waiting on the other's round trips. + const [failedImageNames, results] = await Promise.all([ + failedImageNamesPromise, + Promise.allSettled(videoMutations.map(({ promise }) => promise)), + ]); const failed = results.filter((result) => result.status === 'rejected'); - if (failed.length === 0) { + // Checked before *any* of the writes below, the reset included: all of them land after an + // unbounded await, and the reset is as capable of clearing a selection that now belongs to + // someone else as the retain is of overwriting it. + if (!canRetainFailedSelection(selectChangeBoardModalSlice(store.getState()), isSameAuthContext(authContext))) { + return; + } + if (failed.length === 0 && failedImageNames.length === 0) { dispatch(changeBoardReset()); return; } + // At most one of these fires: the two selections are mutually exclusive by construction — + // imagesToChangeSelected clears video_names and videosToChangeSelected clears image_names, + // and every caller opens this dialog through one of them. + if (failedImageNames.length > 0) { + dispatch(imagesToChangeSelected(failedImageNames)); + } + if (failed.length === 0) { + return; + } const failedVideoNames = results.flatMap((result, index) => result.status === 'rejected' && videoMutations[index] ? [videoMutations[index].videoName] : [] ); @@ -135,6 +170,7 @@ const ChangeBoardModal = () => { removeImagesFromBoard, removeVideoFromBoard, selectedBoardId, + store, t, videosToChange, ]); diff --git a/invokeai/frontend/web/src/features/changeBoardModal/store/slice.ts b/invokeai/frontend/web/src/features/changeBoardModal/store/slice.ts index dadf7a43b36..de088948be6 100644 --- a/invokeai/frontend/web/src/features/changeBoardModal/store/slice.ts +++ b/invokeai/frontend/web/src/features/changeBoardModal/store/slice.ts @@ -38,6 +38,30 @@ const slice = createSlice({ export const { isModalOpenChanged, imagesToChangeSelected, videosToChangeSelected, changeBoardReset } = slice.actions; +/** + * Whether a completed move may write the names it could not move back into the modal's pending + * selection. + * + * The write lands long after the dialog has closed — ConfirmationAlertDialog calls + * acceptCallback and then onClose without awaiting, so `changeBoardReset` has already run — and + * the slice it writes into is shared by every opener. Two things can have happened in between: + * + * - Another selection can have claimed the modal. Right-click a different image while a large + * move is in flight and the dialog is open again with one name in it; overwriting that with + * the earlier request's failures would move a set the user never chose, to the board they + * picked for something else. + * - The session can have ended. The logout listener clears this slice along with the api state, + * and re-seeding it afterwards would leave one user's image names in the next user's store. + */ +export const canRetainFailedSelection = ( + modalState: { isModalOpen: boolean; image_names: string[]; video_names: string[] }, + isSameSession: boolean +): boolean => + isSameSession && + !modalState.isModalOpen && + modalState.image_names.length === 0 && + modalState.video_names.length === 0; + export const selectChangeBoardModalSlice = (state: RootState) => state.changeBoardModal; export const changeBoardModalSliceConfig: SliceConfig = { From 46c47b2ae6abae70e14ad18e17b79795da73d635 Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Thu, 20 Aug 2026 09:07:54 -0500 Subject: [PATCH 11/34] fix(ui): guard async change-board results --- .../frontend/web/src/app/store/store.test.ts | 1 + invokeai/frontend/web/src/app/store/store.ts | 6 +-- .../components/ChangeBoardModal.test.ts | 40 +++++++++++++++---- .../components/ChangeBoardModal.tsx | 16 +++++--- .../features/changeBoardModal/store/slice.ts | 36 ++++++++++++----- .../src/services/api/endpoints/images.test.ts | 19 +++++++++ .../web/src/services/api/endpoints/images.ts | 35 +++++++--------- 7 files changed, 108 insertions(+), 45 deletions(-) diff --git a/invokeai/frontend/web/src/app/store/store.test.ts b/invokeai/frontend/web/src/app/store/store.test.ts index 64dcdfe3af6..efbf5f8faef 100644 --- a/invokeai/frontend/web/src/app/store/store.test.ts +++ b/invokeai/frontend/web/src/app/store/store.test.ts @@ -98,6 +98,7 @@ describe('auth cache isolation', () => { store.dispatch(logOut()); expect(store.getState().changeBoardModal).toMatchObject({ + operation_id: 2, isModalOpen: false, image_names: [], video_names: [], diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts index 59551225314..10ea0419c97 100644 --- a/invokeai/frontend/web/src/app/store/store.ts +++ b/invokeai/frontend/web/src/app/store/store.ts @@ -33,7 +33,7 @@ import { sessionExpiredLogout, tokensBelongToSameUser, } from 'features/auth/store/authSlice'; -import { changeBoardModalSliceConfig, changeBoardReset } from 'features/changeBoardModal/store/slice'; +import { changeBoardModalSliceConfig, changeBoardOperationInvalidated } from 'features/changeBoardModal/store/slice'; import { canvasSettingsSliceConfig } from 'features/controlLayers/store/canvasSettingsSlice'; import { canvasSliceConfig } from 'features/controlLayers/store/canvasSlice'; import { canvasSessionSliceConfig } from 'features/controlLayers/store/canvasStagingAreaSlice'; @@ -288,7 +288,7 @@ startAppListening({ matcher: isAnyOf(logout, sessionExpiredLogout), effect: (_action, { dispatch }) => { dispatch(api.util.resetApiState()); - dispatch(changeBoardReset()); + dispatch(changeBoardOperationInvalidated()); cancelDeletion(); }, }); @@ -300,7 +300,7 @@ startAppListening({ return; } dispatch(api.util.resetApiState()); - dispatch(changeBoardReset()); + dispatch(changeBoardOperationInvalidated()); cancelDeletion(); }, }); diff --git a/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.test.ts b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.test.ts index 606d46b2e27..5facec0d96d 100644 --- a/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.test.ts +++ b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.test.ts @@ -1,29 +1,55 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; -import { canRetainFailedSelection } from 'features/changeBoardModal/store/slice'; +import { + canRetainFailedSelection, + changeBoardModalSliceConfig, + changeBoardOperationInvalidated, + changeBoardReset, + imagesToChangeSelected, +} from 'features/changeBoardModal/store/slice'; import { describe, expect, it } from 'vitest'; describe('canRetainFailedSelection', () => { - const unclaimed = { isModalOpen: false, image_names: [], video_names: [] }; + const unclaimed = { operation_id: 3, isModalOpen: false, image_names: [], video_names: [] }; it('allows the write-back when nothing has claimed the modal since', () => { - expect(canRetainFailedSelection(unclaimed, true)).toBe(true); + expect(canRetainFailedSelection(unclaimed, 3, true)).toBe(true); }); it('refuses once another selection has claimed the modal', () => { // Right-click a different image while a large move is in flight: the dialog is open again // with that one name in it. Overwriting it with the earlier request's failures would move // a set the user never chose, to the board they picked for something else. - expect(canRetainFailedSelection({ ...unclaimed, isModalOpen: true }, true)).toBe(false); - expect(canRetainFailedSelection({ ...unclaimed, image_names: ['other.png'] }, true)).toBe(false); - expect(canRetainFailedSelection({ ...unclaimed, video_names: ['other.mp4'] }, true)).toBe(false); + expect(canRetainFailedSelection({ ...unclaimed, isModalOpen: true }, 3, true)).toBe(false); + expect(canRetainFailedSelection({ ...unclaimed, image_names: ['other.png'] }, 3, true)).toBe(false); + expect(canRetainFailedSelection({ ...unclaimed, video_names: ['other.mp4'] }, 3, true)).toBe(false); + }); + + it('refuses after a newer selection was opened and canceled', () => { + expect(canRetainFailedSelection(unclaimed, 4, true)).toBe(false); }); it('refuses once the session has ended', () => { // The logout listener clears this slice along with the api state; re-seeding it afterwards // leaves one user's image names in the next user's store. - expect(canRetainFailedSelection(unclaimed, false)).toBe(false); + expect(canRetainFailedSelection(unclaimed, 3, false)).toBe(false); + }); +}); + +describe('change board operation ownership', () => { + it('advances on selection but preserves ownership across the accept reset', () => { + const reducer = changeBoardModalSliceConfig.slice.reducer; + const selected = reducer(undefined, imagesToChangeSelected(['first.png'])); + const reset = reducer(selected, changeBoardReset()); + const newer = reducer(reset, imagesToChangeSelected(['second.png'])); + const invalidated = reducer(newer, changeBoardOperationInvalidated()); + + expect(selected.operation_id).toBe(1); + expect(reset.operation_id).toBe(1); + expect(newer.operation_id).toBe(2); + expect(invalidated.operation_id).toBe(3); + expect(invalidated.image_names).toEqual([]); }); }); diff --git a/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx index 058c2b8a8d9..8c81ca85642 100644 --- a/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx +++ b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx @@ -96,11 +96,9 @@ const ChangeBoardModal = () => { // (ConfirmationAlertDialog calls acceptCallback then onClose) — so the names that did not // move used to be dropped along with the ones that did, leaving nothing to retry from. // - // Per-name failures are toasted by the endpoint itself (`onQueryStarted`), so what is left - // to do here is keep those names selected. A whole-request rejection is different: it means - // none of them moved, and nothing toasts those today — neither the endpoint's handler - // (documented no-op) nor any matchRejected listener, since only the single-image board - // routes have one. + // Per-name failures and whole-request failures are toasted by the endpoint itself. What is + // left to do here is keep names that need a retry selected. + const operationId = selectChangeBoardModalSlice(store.getState()).operation_id; const failedImageNamesPromise: Promise = !imagesToChange.length ? Promise.resolve([]) : (selectedBoardId === 'none' @@ -137,7 +135,13 @@ const ChangeBoardModal = () => { // Checked before *any* of the writes below, the reset included: all of them land after an // unbounded await, and the reset is as capable of clearing a selection that now belongs to // someone else as the retain is of overwriting it. - if (!canRetainFailedSelection(selectChangeBoardModalSlice(store.getState()), isSameAuthContext(authContext))) { + if ( + !canRetainFailedSelection( + selectChangeBoardModalSlice(store.getState()), + operationId, + isSameAuthContext(authContext) + ) + ) { return; } if (failed.length === 0 && failedImageNames.length === 0) { diff --git a/invokeai/frontend/web/src/features/changeBoardModal/store/slice.ts b/invokeai/frontend/web/src/features/changeBoardModal/store/slice.ts index de088948be6..502428d311d 100644 --- a/invokeai/frontend/web/src/features/changeBoardModal/store/slice.ts +++ b/invokeai/frontend/web/src/features/changeBoardModal/store/slice.ts @@ -5,6 +5,7 @@ import type { SliceConfig } from 'app/store/types'; import z from 'zod'; const zChangeBoardModalState = z.object({ + operation_id: z.number().int().nonnegative().default(0), isModalOpen: z.boolean().default(false), image_names: z.array(z.string()).default(() => []), video_names: z.array(z.string()).default(() => []), @@ -21,10 +22,12 @@ const slice = createSlice({ state.isModalOpen = action.payload; }, imagesToChangeSelected: (state, action: PayloadAction) => { + state.operation_id += 1; state.image_names = action.payload; state.video_names = []; }, videosToChangeSelected: (state, action: PayloadAction) => { + state.operation_id += 1; state.video_names = action.payload; state.image_names = []; }, @@ -33,30 +36,45 @@ const slice = createSlice({ state.video_names = []; state.isModalOpen = false; }, + changeBoardOperationInvalidated: (state) => { + state.operation_id += 1; + state.image_names = []; + state.video_names = []; + state.isModalOpen = false; + }, }, }); -export const { isModalOpenChanged, imagesToChangeSelected, videosToChangeSelected, changeBoardReset } = slice.actions; +export const { + isModalOpenChanged, + imagesToChangeSelected, + videosToChangeSelected, + changeBoardReset, + changeBoardOperationInvalidated, +} = slice.actions; /** * Whether a completed move may write the names it could not move back into the modal's pending - * selection. + * selection. Operation ID must still match the move that completed. * * The write lands long after the dialog has closed — ConfirmationAlertDialog calls * acceptCallback and then onClose without awaiting, so `changeBoardReset` has already run — and * the slice it writes into is shared by every opener. Two things can have happened in between: * - * - Another selection can have claimed the modal. Right-click a different image while a large - * move is in flight and the dialog is open again with one name in it; overwriting that with - * the earlier request's failures would move a set the user never chose, to the board they - * picked for something else. - * - The session can have ended. The logout listener clears this slice along with the api state, - * and re-seeding it afterwards would leave one user's image names in the next user's store. + * - Another selection can have claimed and released the modal. Right-click a different image + * while a large move is in flight, then cancel that second dialog; the state is empty and + * closed again, but its operation ID is newer. Without that ID, the earlier request's + * failures are accepted and can later be moved to the wrong board. + * - The session can have ended. The logout listener invalidates this operation along with the + * api state, and re-seeding it afterwards would leave one user's image names in the next + * user's store. */ export const canRetainFailedSelection = ( - modalState: { isModalOpen: boolean; image_names: string[]; video_names: string[] }, + modalState: { operation_id: number; isModalOpen: boolean; image_names: string[]; video_names: string[] }, + operationId: number, isSameSession: boolean ): boolean => + modalState.operation_id === operationId && isSameSession && !modalState.isModalOpen && modalState.image_names.length === 0 && diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts index 073a7a61ac5..12427742ff0 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts @@ -7,6 +7,7 @@ import { imageDTOsByNamesQueryFn, imagesApi, mergeImageBatchResults, + toastFailedImageBatch, } from 'services/api/endpoints/images'; import type { ImageDTO } from 'services/api/types'; import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -92,6 +93,24 @@ describe('mergeImageBatchResults', () => { }); }); +describe('toastFailedImageBatch', () => { + beforeEach(() => { + vi.mocked(toast).mockClear(); + vi.mocked(i18n.t).mockClear(); + }); + + it('reports every unique name when the first request fails', () => { + toastFailedImageBatch(['a.png', 'a.png', 'b.png']); + + expect(i18n.t).toHaveBeenCalledWith('toast.imagesFailedToUpdate', { count: 2 }); + expect(toast).toHaveBeenCalledWith({ + id: 'IMAGES_FAILED_TO_UPDATE', + title: 'toast.imagesFailedToUpdate', + status: 'warning', + }); + }); +}); + describe('buildChunkedImageBatchQueryFn', () => { type Arg = { image_names: string[]; board_id?: string }; type Result = { added_images: string[]; failed_images: string[]; affected_boards: string[] }; diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index 912e63c8258..ccb608d97c0 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -270,6 +270,11 @@ const toastFailedImages = (count: number) => { } }; +/** Reports a whole batch that failed before its first request could commit. */ +export const toastFailedImageBatch = (image_names: string[]) => { + toastFailedImages(new Set(image_names).size); +}; + /** * The download counterpart. Distinct id and wording: "could not be updated" is wrong for a * download, and sharing the id would let one warning overwrite the other, since the toast @@ -497,16 +502,14 @@ export const imagesApi = api.injectEndpoints({ () => ({ url: buildImagesUrl('delete'), method: 'POST' }), getDeleteImagesTags ), - async onQueryStarted(_, { queryFulfilled }) { + async onQueryStarted({ image_names }, { queryFulfilled }) { try { const { data: result } = await queryFulfilled; // `handleDeletions` swallows every outcome, so without this a delete that only // partly landed — server-side failures or chunks never reached — said nothing at all. toastFailedImages(result.failed_images.length); } catch { - // A rejection means nothing landed at all -- a partial run resolves with data, - // not an error. Nothing toasts these rejections today; that gap is unchanged - // by this handler, which exists only to surface per-name failures. + toastFailedImageBatch(image_names); } }, invalidatesTags: (result) => (result ? getDeleteImagesTags(result) : []), @@ -563,14 +566,12 @@ export const imagesApi = api.injectEndpoints({ () => ({ url: buildImagesUrl('star'), method: 'POST' }), getStarImagesTags ), - async onQueryStarted(_, { queryFulfilled }) { + async onQueryStarted({ image_names }, { queryFulfilled }) { try { const { data: result } = await queryFulfilled; toastFailedImages(result.failed_images.length); } catch { - // A rejection means nothing landed at all -- a partial run resolves with data, - // not an error. Nothing toasts these rejections today; that gap is unchanged - // by this handler, which exists only to surface per-name failures. + toastFailedImageBatch(image_names); } }, invalidatesTags: (result) => (result ? getStarImagesTags(result) : []), @@ -586,14 +587,12 @@ export const imagesApi = api.injectEndpoints({ () => ({ url: buildImagesUrl('unstar'), method: 'POST' }), getUnstarImagesTags ), - async onQueryStarted(_, { queryFulfilled }) { + async onQueryStarted({ image_names }, { queryFulfilled }) { try { const { data: result } = await queryFulfilled; toastFailedImages(result.failed_images.length); } catch { - // A rejection means nothing landed at all -- a partial run resolves with data, - // not an error. Nothing toasts these rejections today; that gap is unchanged - // by this handler, which exists only to surface per-name failures. + toastFailedImageBatch(image_names); } }, invalidatesTags: (result) => (result ? getUnstarImagesTags(result) : []), @@ -760,14 +759,12 @@ export const imagesApi = api.injectEndpoints({ () => ({ url: buildBoardImagesUrl('batch'), method: 'POST' }), getAddImagesToBoardTags ), - async onQueryStarted(_, { queryFulfilled }) { + async onQueryStarted({ image_names }, { queryFulfilled }) { try { const { data: result } = await queryFulfilled; toastFailedImages(result.failed_images.length); } catch { - // A rejection means nothing landed at all -- a partial run resolves with data, - // not an error. Nothing toasts these rejections today; that gap is unchanged - // by this handler, which exists only to surface per-name failures. + toastFailedImageBatch(image_names); } }, invalidatesTags: (result) => (result ? getAddImagesToBoardTags(result) : []), @@ -780,14 +777,12 @@ export const imagesApi = api.injectEndpoints({ () => ({ url: buildBoardImagesUrl('batch/delete'), method: 'POST' }), getRemoveImagesFromBoardTags ), - async onQueryStarted(_, { queryFulfilled }) { + async onQueryStarted({ image_names }, { queryFulfilled }) { try { const { data: result } = await queryFulfilled; toastFailedImages(result.failed_images.length); } catch { - // A rejection means nothing landed at all -- a partial run resolves with data, - // not an error. Nothing toasts these rejections today; that gap is unchanged - // by this handler, which exists only to surface per-name failures. + toastFailedImageBatch(image_names); } }, invalidatesTags: (result) => (result ? getRemoveImagesFromBoardTags(result) : []), From 55b370e455427802b74d6da1622b28a84736d57a Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Thu, 20 Aug 2026 20:53:58 -0400 Subject: [PATCH 12/34] fix(ui): report a failed video move regardless of who owns the modal The ownership guard added in 46c47b2 sits ahead of the VIDEOS_FAILED_TO_MOVE toast, which is the only failure report the video board routes have: they have no `onQueryStarted` handler and no `matchRejected` listener, unlike the image batch routes, which now toast from the endpoint and so are unaffected by anything the modal decides. Open and cancel any second dialog while a video move is in flight and the guard refuses, so a move that failed says nothing at all. Reported ahead of the guard now, gated on the session alone -- the guard protects a shared slice from a stale write, it does not decide who is told about a request they started themselves. The two halves of that commit were also invisible to the tests: deleting the rejection branch from all five `onQueryStarted` handlers, or reading `operationId` after the await (which makes the guard compare the current value against itself), both left the suite green. The five identical handlers are now one exported `reportImageBatchOutcome`, tested on both branches, with a source guard counting its wiring against the chunked endpoints so a sixth one cannot forget it; the capture ordering and the toast ordering get source guards in the manner of the ones already in this file. Also corrects the new `canRetainFailedSelection` docstring: the stale retain cannot reach a wrong-board move, because all four openers re-seed the selection before showing the dialog. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/ChangeBoardModal.test.ts | 26 +++++++ .../components/ChangeBoardModal.tsx | 32 +++++--- .../features/changeBoardModal/store/slice.ts | 7 +- .../src/services/api/endpoints/images.test.ts | 56 ++++++++++++++ .../web/src/services/api/endpoints/images.ts | 75 ++++++++----------- 5 files changed, 140 insertions(+), 56 deletions(-) diff --git a/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.test.ts b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.test.ts index 5facec0d96d..d2677cbbbad 100644 --- a/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.test.ts +++ b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.test.ts @@ -77,4 +77,30 @@ describe('ChangeBoardModal', () => { // A rejected request moved nothing at all, so the whole request stays selected. expect(source).toContain('.catch(() => imagesToChange)'); }); + + it('captures the operation id before awaiting the move, not after it', () => { + // The whole ownership check rests on this ordering. Read after the await, the id is + // whatever the slice holds once every newer selection has already come and gone, so the + // guard compares the current value against itself and admits every stale operation -- + // silently, since it still typechecks and every other assertion here still passes. + const capture = source.indexOf('const operationId ='); + const settle = source.indexOf('await Promise.all'); + + expect(capture).toBeGreaterThan(-1); + expect(settle).toBeGreaterThan(-1); + expect(capture).toBeLessThan(settle); + }); + + it('reports failed video moves ahead of the ownership guard', () => { + // This toast is the only failure report the video board routes have: no onQueryStarted, no + // matchRejected listener, unlike the image batch routes. Behind the guard, opening and + // cancelling any second dialog while the move was in flight leaves the user with no notice + // at all that it failed. + const videoToast = source.indexOf('VIDEOS_FAILED_TO_MOVE'); + const guard = source.indexOf('canRetainFailedSelection('); + + expect(videoToast).toBeGreaterThan(-1); + expect(guard).toBeGreaterThan(-1); + expect(videoToast).toBeLessThan(guard); + }); }); diff --git a/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx index 8c81ca85642..654f765160d 100644 --- a/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx +++ b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx @@ -132,16 +132,29 @@ const ChangeBoardModal = () => { Promise.allSettled(videoMutations.map(({ promise }) => promise)), ]); const failed = results.filter((result) => result.status === 'rejected'); + const isSameSession = isSameAuthContext(authContext); + + // Reported ahead of the ownership guard below, not behind it. This toast is the only failure + // report the video board routes have — unlike the image batch routes, they have no + // `onQueryStarted` handler and no `matchRejected` listener, so nothing else says a word if it + // does not fire. Behind the guard, opening and cancelling any second dialog while this move + // was in flight would leave the user with no notice at all that their move failed, and the + // guard exists to protect a shared slice from a stale write, not to decide who gets told + // about a request they themselves started. Only the session check applies to it: the failure + // belongs to whoever started the move, so it is not raised at whoever holds the tab after a + // logout. + if (failed.length > 0 && isSameSession) { + toast({ + id: 'VIDEOS_FAILED_TO_MOVE', + title: t('toast.videosFailedToMove', { count: failed.length }), + status: 'warning', + }); + } + // Checked before *any* of the writes below, the reset included: all of them land after an // unbounded await, and the reset is as capable of clearing a selection that now belongs to // someone else as the retain is of overwriting it. - if ( - !canRetainFailedSelection( - selectChangeBoardModalSlice(store.getState()), - operationId, - isSameAuthContext(authContext) - ) - ) { + if (!canRetainFailedSelection(selectChangeBoardModalSlice(store.getState()), operationId, isSameSession)) { return; } if (failed.length === 0 && failedImageNames.length === 0) { @@ -161,11 +174,6 @@ const ChangeBoardModal = () => { result.status === 'rejected' && videoMutations[index] ? [videoMutations[index].videoName] : [] ); dispatch(videosToChangeSelected(failedVideoNames)); - toast({ - id: 'VIDEOS_FAILED_TO_MOVE', - title: t('toast.videosFailedToMove', { count: failed.length }), - status: 'warning', - }); }, [ addImagesToBoard, addVideoToBoard, diff --git a/invokeai/frontend/web/src/features/changeBoardModal/store/slice.ts b/invokeai/frontend/web/src/features/changeBoardModal/store/slice.ts index 502428d311d..394c18accd8 100644 --- a/invokeai/frontend/web/src/features/changeBoardModal/store/slice.ts +++ b/invokeai/frontend/web/src/features/changeBoardModal/store/slice.ts @@ -63,8 +63,11 @@ export const { * * - Another selection can have claimed and released the modal. Right-click a different image * while a large move is in flight, then cancel that second dialog; the state is empty and - * closed again, but its operation ID is newer. Without that ID, the earlier request's - * failures are accepted and can later be moved to the wrong board. + * closed again, but its operation ID is newer. Without that ID the slice looks unclaimed and + * the earlier request's failures are written in under the newer operation's identity. No + * opener surfaces them as things stand — every one of the four re-seeds the selection before + * it shows the dialog — so this half of the check holds the invariant rather than closing a + * reachable path, and it is what lets the retain stay a plain write into shared state. * - The session can have ended. The logout listener invalidates this operation along with the * api state, and re-seeding it afterwards would leave one user's image names in the next * user's store. diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts index 12427742ff0..6678dd7aba0 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts @@ -1,3 +1,6 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + import { toast } from 'features/toast/toast'; import i18n from 'i18next'; import { @@ -7,6 +10,7 @@ import { imageDTOsByNamesQueryFn, imagesApi, mergeImageBatchResults, + reportImageBatchOutcome, toastFailedImageBatch, } from 'services/api/endpoints/images'; import type { ImageDTO } from 'services/api/types'; @@ -111,6 +115,58 @@ describe('toastFailedImageBatch', () => { }); }); +describe('reportImageBatchOutcome', () => { + beforeEach(() => { + vi.mocked(toast).mockClear(); + vi.mocked(i18n.t).mockClear(); + }); + + it('reports the names the server could not apply when the request resolves', async () => { + await reportImageBatchOutcome( + { image_names: names(3) }, + { queryFulfilled: Promise.resolve({ data: { failed_images: ['image-1.png'] } }) } + ); + + expect(i18n.t).toHaveBeenCalledWith('toast.imagesFailedToUpdate', { count: 1 }); + }); + + it('says nothing when every name was applied', async () => { + await reportImageBatchOutcome( + { image_names: names(3) }, + { queryFulfilled: Promise.resolve({ data: { failed_images: [] } }) } + ); + + expect(toast).not.toHaveBeenCalled(); + }); + + it('reports the whole argument list when the request rejects', async () => { + // A rejection out of the chunked queryFn is raised only when nothing was committed, so + // every name really is unapplied. Swallowing it leaves a delete or a move that landed + // nothing saying nothing at all -- these endpoints have no matchRejected listener. + await reportImageBatchOutcome({ image_names: names(3) }, { queryFulfilled: Promise.reject(new Error('boom')) }); + + expect(i18n.t).toHaveBeenCalledWith('toast.imagesFailedToUpdate', { count: 3 }); + expect(toast).toHaveBeenCalledWith({ + id: 'IMAGES_FAILED_TO_UPDATE', + title: 'toast.imagesFailedToUpdate', + status: 'warning', + }); + }); + + it('is wired into every chunked batch mutation', () => { + // RTK exposes no way to reach an endpoint's `onQueryStarted` at runtime -- the built + // endpoint object carries only initiate/select/match*/hooks -- so the wiring is guarded at + // the source level, as elsewhere in this repo. Counted against the chunked endpoints rather + // than a fixed number, so a sixth one that forgets to report its failures fails this. + const source = readFileSync(fileURLToPath(new URL('./images.ts', import.meta.url)), 'utf8'); + const chunked = source.match(/queryFn: buildChunkedImageBatchQueryFn\(/g) ?? []; + const wired = source.match(/onQueryStarted: reportImageBatchOutcome,/g) ?? []; + + expect(chunked.length).toBeGreaterThan(0); + expect(wired).toHaveLength(chunked.length); + }); +}); + describe('buildChunkedImageBatchQueryFn', () => { type Arg = { image_names: string[]; board_id?: string }; type Result = { added_images: string[]; failed_images: string[]; affected_boards: string[] }; diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index ccb608d97c0..43a722fc4fc 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -275,6 +275,34 @@ export const toastFailedImageBatch = (image_names: string[]) => { toastFailedImages(new Set(image_names).size); }; +/** + * The outcome handler shared by all five batch mutations, which report failure identically. + * + * Both branches matter and neither is reachable from anywhere else. `handleDeletions` and its + * siblings swallow every outcome, so without the fulfilled branch a run that only partly landed + * — server-side per-name failures, or chunks a mid-run failure never reached — says nothing at + * all. And a rejection out of `buildChunkedImageBatchQueryFn` is only ever raised when + * `results.length === 0`, i.e. the first chunk failed and the server committed nothing, so the + * whole argument list is genuinely unapplied and reporting all of it is exact rather than an + * over-count. Nothing else covers that case: these five endpoints have no `matchRejected` + * listener, unlike the single-image board routes. + * + * Extracted rather than repeated inline five times so that the wiring is one unit a test can + * hold — an endpoint that swallows its rejection looks identical to one that reports it, and + * that difference is invisible to a test of the toast helpers alone. + */ +export const reportImageBatchOutcome = async ( + { image_names }: { image_names: string[] }, + { queryFulfilled }: { queryFulfilled: Promise<{ data: { failed_images: string[] } }> } +) => { + try { + const { data: result } = await queryFulfilled; + toastFailedImages(result.failed_images.length); + } catch { + toastFailedImageBatch(image_names); + } +}; + /** * The download counterpart. Distinct id and wording: "could not be updated" is wrong for a * download, and sharing the id would let one warning overwrite the other, since the toast @@ -502,16 +530,7 @@ export const imagesApi = api.injectEndpoints({ () => ({ url: buildImagesUrl('delete'), method: 'POST' }), getDeleteImagesTags ), - async onQueryStarted({ image_names }, { queryFulfilled }) { - try { - const { data: result } = await queryFulfilled; - // `handleDeletions` swallows every outcome, so without this a delete that only - // partly landed — server-side failures or chunks never reached — said nothing at all. - toastFailedImages(result.failed_images.length); - } catch { - toastFailedImageBatch(image_names); - } - }, + onQueryStarted: reportImageBatchOutcome, invalidatesTags: (result) => (result ? getDeleteImagesTags(result) : []), }), deleteUncategorizedImages: build.mutation< @@ -566,14 +585,7 @@ export const imagesApi = api.injectEndpoints({ () => ({ url: buildImagesUrl('star'), method: 'POST' }), getStarImagesTags ), - async onQueryStarted({ image_names }, { queryFulfilled }) { - try { - const { data: result } = await queryFulfilled; - toastFailedImages(result.failed_images.length); - } catch { - toastFailedImageBatch(image_names); - } - }, + onQueryStarted: reportImageBatchOutcome, invalidatesTags: (result) => (result ? getStarImagesTags(result) : []), }), /** @@ -587,14 +599,7 @@ export const imagesApi = api.injectEndpoints({ () => ({ url: buildImagesUrl('unstar'), method: 'POST' }), getUnstarImagesTags ), - async onQueryStarted({ image_names }, { queryFulfilled }) { - try { - const { data: result } = await queryFulfilled; - toastFailedImages(result.failed_images.length); - } catch { - toastFailedImageBatch(image_names); - } - }, + onQueryStarted: reportImageBatchOutcome, invalidatesTags: (result) => (result ? getUnstarImagesTags(result) : []), }), uploadImage: build.mutation< @@ -759,14 +764,7 @@ export const imagesApi = api.injectEndpoints({ () => ({ url: buildBoardImagesUrl('batch'), method: 'POST' }), getAddImagesToBoardTags ), - async onQueryStarted({ image_names }, { queryFulfilled }) { - try { - const { data: result } = await queryFulfilled; - toastFailedImages(result.failed_images.length); - } catch { - toastFailedImageBatch(image_names); - } - }, + onQueryStarted: reportImageBatchOutcome, invalidatesTags: (result) => (result ? getAddImagesToBoardTags(result) : []), }), removeImagesFromBoard: build.mutation< @@ -777,14 +775,7 @@ export const imagesApi = api.injectEndpoints({ () => ({ url: buildBoardImagesUrl('batch/delete'), method: 'POST' }), getRemoveImagesFromBoardTags ), - async onQueryStarted({ image_names }, { queryFulfilled }) { - try { - const { data: result } = await queryFulfilled; - toastFailedImages(result.failed_images.length); - } catch { - toastFailedImageBatch(image_names); - } - }, + onQueryStarted: reportImageBatchOutcome, invalidatesTags: (result) => (result ? getRemoveImagesFromBoardTags(result) : []), }), bulkDownloadImages: build.mutation< From 5dcdcdd5c3547abbc3390f42a28c7f028dbe1810 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Thu, 20 Aug 2026 21:09:53 -0400 Subject: [PATCH 13/34] fix(ui): close the holes an adversarial pass found in the new guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An adversarial review of 55b370e built working implementations that defeat all three source-level guards it added, and falsified two claims in its comments. - The wiring count matched `queryFn: buildChunkedImageBatchQueryFn(`, so hoisting the call to a const was enough to add a sixth chunked mutation that swallows every failure and still pass. Matched wherever the call appears now. - The capture-ordering guard only pinned the line order, so re-reading the slice and passing today's value as both operands left it green while admitting every stale operation. The call now has to hand the guard the captured constant. - The video-toast guard only pinned position, so re-checking ownership inside the toast's own condition put it back behind the guard by another route. The toast's gate is now asserted not to mention the operation at all. Each is verified against the implementation that defeated its predecessor. Two comments were also wrong. `settleVideoBoardMutations` (the drag-and-drop path) emits the same VIDEOS_FAILED_TO_MOVE id for the same two routes, so this toast is not "the only failure report the video board routes have" — it is the only one for a move made from this dialog, since that helper settles only the mutations it fired itself. And a rejection reaching `reportImageBatchOutcome` is not unconditionally a nothing-committed run: the mid-run path can throw out of `getTags(merged)` or `merged.failed_images.concat(...)`, both of which read keys off a server payload, which over-counts. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/ChangeBoardModal.test.ts | 12 ++++++++++++ .../components/ChangeBoardModal.tsx | 15 ++++++++------- .../web/src/services/api/endpoints/images.test.ts | 6 ++++-- .../web/src/services/api/endpoints/images.ts | 7 ++++++- 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.test.ts b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.test.ts index d2677cbbbad..a6e986c2e0a 100644 --- a/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.test.ts +++ b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.test.ts @@ -89,6 +89,12 @@ describe('ChangeBoardModal', () => { expect(capture).toBeGreaterThan(-1); expect(settle).toBeGreaterThan(-1); expect(capture).toBeLessThan(settle); + // And that it is the captured constant the guard is handed, not the slice re-read a second + // time: passing today's value as both operands leaves the ordering above intact and still + // admits every stale operation. + expect(source).toMatch( + /canRetainFailedSelection\(\s*selectChangeBoardModalSlice\(store\.getState\(\)\),\s*operationId,/ + ); }); it('reports failed video moves ahead of the ownership guard', () => { @@ -102,5 +108,11 @@ describe('ChangeBoardModal', () => { expect(videoToast).toBeGreaterThan(-1); expect(guard).toBeGreaterThan(-1); expect(videoToast).toBeLessThan(guard); + // Sitting ahead of the guard is not enough on its own -- re-checking ownership in the + // toast's own condition puts it back behind the guard by another route. The failure is + // reported to whoever started the move whatever owns the modal by the time it lands. + const toastGate = source.slice(source.lastIndexOf('if (', videoToast), videoToast); + expect(toastGate).toContain('failed.length > 0'); + expect(toastGate).not.toContain('operation'); }); }); diff --git a/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx index 654f765160d..26f6810dfbb 100644 --- a/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx +++ b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx @@ -134,13 +134,14 @@ const ChangeBoardModal = () => { const failed = results.filter((result) => result.status === 'rejected'); const isSameSession = isSameAuthContext(authContext); - // Reported ahead of the ownership guard below, not behind it. This toast is the only failure - // report the video board routes have — unlike the image batch routes, they have no - // `onQueryStarted` handler and no `matchRejected` listener, so nothing else says a word if it - // does not fire. Behind the guard, opening and cancelling any second dialog while this move - // was in flight would leave the user with no notice at all that their move failed, and the - // guard exists to protect a shared slice from a stale write, not to decide who gets told - // about a request they themselves started. Only the session check applies to it: the failure + // Reported ahead of the ownership guard below, not behind it. Nothing else reports a move + // made from this dialog: the video board routes carry no `onQueryStarted` and no + // `matchRejected` listener, unlike the image batch routes, and the one other emitter of this + // toast id — `settleVideoBoardMutations`, on the drag-and-drop path — only ever settles the + // mutations it fired itself. So behind the guard, opening and cancelling any second dialog + // while this move was in flight would leave the user with no notice at all that it failed. + // The guard exists to keep a stale write out of a shared slice, not to decide who gets told + // about a request they themselves started. Only the session check applies here: the failure // belongs to whoever started the move, so it is not raised at whoever holds the tab after a // logout. if (failed.length > 0 && isSameSession) { diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts index 6678dd7aba0..06c3e1e4ab3 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts @@ -157,9 +157,11 @@ describe('reportImageBatchOutcome', () => { // RTK exposes no way to reach an endpoint's `onQueryStarted` at runtime -- the built // endpoint object carries only initiate/select/match*/hooks -- so the wiring is guarded at // the source level, as elsewhere in this repo. Counted against the chunked endpoints rather - // than a fixed number, so a sixth one that forgets to report its failures fails this. + // than a fixed number, so a sixth one that forgets to report its failures fails this. The + // call sites are matched wherever they are, not only inline after `queryFn:`, since hoisting + // one to a const is otherwise enough to slip an unreporting endpoint past this. const source = readFileSync(fileURLToPath(new URL('./images.ts', import.meta.url)), 'utf8'); - const chunked = source.match(/queryFn: buildChunkedImageBatchQueryFn\(/g) ?? []; + const chunked = source.match(/buildChunkedImageBatchQueryFn\(/g) ?? []; const wired = source.match(/onQueryStarted: reportImageBatchOutcome,/g) ?? []; expect(chunked.length).toBeGreaterThan(0); diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index 43a722fc4fc..8aa830304d7 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -281,12 +281,17 @@ export const toastFailedImageBatch = (image_names: string[]) => { * Both branches matter and neither is reachable from anywhere else. `handleDeletions` and its * siblings swallow every outcome, so without the fulfilled branch a run that only partly landed * — server-side per-name failures, or chunks a mid-run failure never reached — says nothing at - * all. And a rejection out of `buildChunkedImageBatchQueryFn` is only ever raised when + * all. And the rejection `buildChunkedImageBatchQueryFn` *returns* is raised only when * `results.length === 0`, i.e. the first chunk failed and the server committed nothing, so the * whole argument list is genuinely unapplied and reporting all of it is exact rather than an * over-count. Nothing else covers that case: these five endpoints have no `matchRejected` * listener, unlike the single-image board routes. * + * The one way to reach this branch with chunks already committed is for the queryFn to *throw* + * on the mid-run path — `getTags(merged)` and `merged.failed_images.concat(...)` both read keys + * straight off a server payload — which over-counts. That needs a response missing a documented + * key, so it is left as an over-report rather than a silence. + * * Extracted rather than repeated inline five times so that the wiring is one unit a test can * hold — an endpoint that swallows its rejection looks identical to one that reports it, and * that difference is invisible to a test of the toast helpers alone. From 806f985e7f84e804c4206d30531987fab9c348ef Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Fri, 21 Aug 2026 06:11:52 -0500 Subject: [PATCH 14/34] fix(images): prevent stale batch results across sessions --- invokeai/app/api/routers/images.py | 7 ++- .../components/ChangeBoardModal.test.ts | 5 ++ .../components/ChangeBoardModal.tsx | 5 +- .../features/changeBoardModal/store/slice.ts | 7 ++- .../src/services/api/endpoints/images.test.ts | 37 ++++++++++----- .../web/src/services/api/endpoints/images.ts | 47 ++++++++++++------- tests/app/routers/test_images.py | 5 +- 7 files changed, 75 insertions(+), 38 deletions(-) diff --git a/invokeai/app/api/routers/images.py b/invokeai/app/api/routers/images.py index 3082e7d630a..daf9dbdf86f 100644 --- a/invokeai/app/api/routers/images.py +++ b/invokeai/app/api/routers/images.py @@ -554,15 +554,14 @@ def delete_images_from_list( except ImageRecordNotFoundException: # The record is already gone — a concurrent session deleted it after this # iteration's ownership check passed. The caller asked for it to be gone and it - # is, so this is a skip, not a storage failure: reporting it in failed_images - # toasts "1 image could not be updated" for an outcome the user got. Matches - # remove_images_from_board, which resolves the same race for board removal. + # is, so report the idempotently satisfied postcondition. The client uses + # deleted_images to remove stale selections and references. # # This is narrow only because image_records.get() no longer translates a # sqlite3.Error into this exception — see the comment there. If that # translation ever comes back, a locked or corrupt database would land here # and a whole failed batch would answer 200 with empty result lists. - continue + deleted_images.add(image_name) except Exception: # A genuine deletion failure (not an auth/404 skip) — report it so the # client can surface a partial-failure warning, matching the video path. diff --git a/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.test.ts b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.test.ts index a6e986c2e0a..d2ac8e72b6c 100644 --- a/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.test.ts +++ b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.test.ts @@ -74,10 +74,15 @@ describe('ChangeBoardModal', () => { it('keeps the images that did not move selected', () => { expect(source).toContain('imagesToChangeSelected(failedImageNames)'); + expect(source).toMatch(/imagesToChangeSelected\(failedImageNames\)[\s\S]*isModalOpenChanged\(true\)/); // A rejected request moved nothing at all, so the whole request stays selected. expect(source).toContain('.catch(() => imagesToChange)'); }); + it('reopens with failed videos selected for retry', () => { + expect(source).toMatch(/videosToChangeSelected\(failedVideoNames\)[\s\S]*isModalOpenChanged\(true\)/); + }); + it('captures the operation id before awaiting the move, not after it', () => { // The whole ownership check rests on this ordering. Read after the await, the id is // whatever the slice holds once every newer selection has already come and gone, so the diff --git a/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx index 26f6810dfbb..d8f0b6b4cd6 100644 --- a/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx +++ b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx @@ -164,9 +164,11 @@ const ChangeBoardModal = () => { } // At most one of these fires: the two selections are mutually exclusive by construction — // imagesToChangeSelected clears video_names and videosToChangeSelected clears image_names, - // and every caller opens this dialog through one of them. + // and every caller opens this dialog through one of them. Reopen the dialog so retained + // failures are actionable instead of inert state after the accept close. if (failedImageNames.length > 0) { dispatch(imagesToChangeSelected(failedImageNames)); + dispatch(isModalOpenChanged(true)); } if (failed.length === 0) { return; @@ -175,6 +177,7 @@ const ChangeBoardModal = () => { result.status === 'rejected' && videoMutations[index] ? [videoMutations[index].videoName] : [] ); dispatch(videosToChangeSelected(failedVideoNames)); + dispatch(isModalOpenChanged(true)); }, [ addImagesToBoard, addVideoToBoard, diff --git a/invokeai/frontend/web/src/features/changeBoardModal/store/slice.ts b/invokeai/frontend/web/src/features/changeBoardModal/store/slice.ts index 394c18accd8..3782ba36e0c 100644 --- a/invokeai/frontend/web/src/features/changeBoardModal/store/slice.ts +++ b/invokeai/frontend/web/src/features/changeBoardModal/store/slice.ts @@ -64,10 +64,9 @@ export const { * - Another selection can have claimed and released the modal. Right-click a different image * while a large move is in flight, then cancel that second dialog; the state is empty and * closed again, but its operation ID is newer. Without that ID the slice looks unclaimed and - * the earlier request's failures are written in under the newer operation's identity. No - * opener surfaces them as things stand — every one of the four re-seeds the selection before - * it shows the dialog — so this half of the check holds the invariant rather than closing a - * reachable path, and it is what lets the retain stay a plain write into shared state. + * the earlier request's failures are written in under the newer operation's identity. The + * owning component reopens the modal only after this check accepts a retained failure, so the + * operation ID still protects the reachable retry path. * - The session can have ended. The logout listener invalidates this operation along with the * api state, and re-seeding it afterwards would leave one user's image names in the next * user's store. diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts index 06c3e1e4ab3..38ff18e110b 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts @@ -153,6 +153,21 @@ describe('reportImageBatchOutcome', () => { }); }); + it('does not report an outcome from a session that ended while it was pending', async () => { + login('user-a'); + let resolveQuery: (value: { data: { failed_images: string[] } }) => void = () => {}; + const queryFulfilled = new Promise<{ data: { failed_images: string[] } }>((resolve) => { + resolveQuery = resolve; + }); + + const outcome = reportImageBatchOutcome({ image_names: names(3) }, { queryFulfilled }); + switchUser('user-b'); + resolveQuery({ data: { failed_images: ['image-1.png'] } }); + await outcome; + + expect(toast).not.toHaveBeenCalled(); + }); + it('is wired into every chunked batch mutation', () => { // RTK exposes no way to reach an endpoint's `onQueryStarted` at runtime -- the built // endpoint object carries only initiate/select/match*/hooks -- so the wiring is guarded at @@ -286,18 +301,16 @@ describe('buildChunkedImageBatchQueryFn', () => { }); }); - const { result } = run(baseQuery, { image_names: names(2500) }); + const { dispatch, result } = run(baseQuery, { image_names: names(2500) }); - // Chunk 3 is never sent. Chunks 1 and 2 already committed, so the run is a partial success - // and its unreached names are reported, exactly as for a mid-run server failure. + // The second response is stale as soon as the session changes, so neither it nor a partial + // aggregate may reach the new session. The server-side write cannot be rolled back here, but + // the next session must refetch its own state rather than consume the old result. expect(await result).toEqual({ - data: { - added_images: ['chunk-1.png', 'chunk-2.png'], - failed_images: names(2500).slice(2000), - affected_boards: ['board-1'], - }, + error: { status: 'CUSTOM_ERROR', error: expect.stringContaining('Aborted') }, }); expect(baseQuery).toHaveBeenCalledTimes(2); + expect(dispatch).not.toHaveBeenCalled(); }); it('keeps running when a login request elsewhere bumps the auth generation', async () => { @@ -469,9 +482,11 @@ describe('bulkDownloadQueryFn', () => { const result = await run(baseQuery, { image_names: names(2500) }); expect(baseQuery).toHaveBeenCalledTimes(1); - expect(result).toEqual({ data: { bulk_download_item_name: 'item-1.zip' } }); - // The 1500 names in the chunks that were never sent are in no zip. - expect(i18n.t).toHaveBeenCalledWith('toast.imagesFailedToDownload', { count: 1500 }); + expect(result).toEqual({ data: undefined }); + // The first response belongs to the previous session. Do not expose its item name or toast + // the new session about work it did not request. + expect(toast).not.toHaveBeenCalled(); + expect(i18n.t).not.toHaveBeenCalled(); }); it('reports an error when the first chunk fails, since nothing was scheduled', async () => { diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index 8aa830304d7..e63ca83ff90 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -76,16 +76,15 @@ type ImagesBaseQuery = ( args: string | FetchArgs ) => QueryReturnValue | PromiseLike>; -/** - * Stands in for the response of a chunk that was never sent because the session changed under - * the operation. Shaped like any other chunk failure so it takes the partial-result path the - * loops already have: what the previous chunks committed is reported, the rest is unreached. - */ +/** A stale response must not be consumed by the session that replaced the requester. */ const AUTH_CHANGED_ERROR: FetchBaseQueryError = { status: 'CUSTOM_ERROR', error: 'Aborted: the authenticated session changed while the operation was running', }; +const isAuthChangedError = (error: FetchBaseQueryError | undefined): boolean => + error?.status === AUTH_CHANGED_ERROR.status && error.error === AUTH_CHANGED_ERROR.error; + /** * Issues one chunk of a multi-request operation, unless the session it started under is gone. * @@ -96,9 +95,10 @@ const AUTH_CHANGED_ERROR: FetchBaseQueryError = { * user's delete is committed under another's name, with nothing to roll it back. Nothing else * stops it: `resetApiState` on the logout action clears the cache, not a running `queryFn`. * - * So the context is captured before the first chunk and rechecked before each one. See - * `isSameAuthContext` for what counts as the same session — notably a sliding-window token - * refresh does not, or every long batch would abandon itself. + * So the context is captured before the first chunk, rechecked before each request, and checked + * again before its response is consumed. See `isSameAuthContext` for what counts as the same + * session — notably a sliding-window token refresh does not, or every long batch would abandon + * itself. */ const fetchChunk = async ( baseQuery: ImagesBaseQuery, @@ -108,7 +108,8 @@ const fetchChunk = async ( if (!isSameAuthContext(authContext)) { return { error: AUTH_CHANGED_ERROR }; } - return await baseQuery(args); + const response = await baseQuery(args); + return isSameAuthContext(authContext) ? response : { error: AUTH_CHANGED_ERROR }; }; export const chunkImageNames = (image_names: string[]): string[][] => { @@ -180,6 +181,11 @@ export const buildChunkedImageBatchQueryFn = for (const [index, image_names] of chunks.entries()) { const response = await fetchChunk(baseQuery, authContext, { ...request(arg), body: { ...arg, image_names } }); if (response.error) { + if (isAuthChangedError(response.error)) { + // A prior chunk may have committed, but its result belongs to the old session. Do not + // invalidate the new session or return partial data that its UI can apply. + return { error: response.error }; + } if (results.length === 0) { // Nothing was applied, so this is an ordinary failed request — report it as one. return { error: response.error }; @@ -300,10 +306,17 @@ export const reportImageBatchOutcome = async ( { image_names }: { image_names: string[] }, { queryFulfilled }: { queryFulfilled: Promise<{ data: { failed_images: string[] } }> } ) => { + const authContext = captureAuthContext(); try { const { data: result } = await queryFulfilled; + if (!isSameAuthContext(authContext)) { + return; + } toastFailedImages(result.failed_images.length); } catch { + if (!isSameAuthContext(authContext)) { + return; + } toastFailedImageBatch(image_names); } }; @@ -373,6 +386,11 @@ export const bulkDownloadQueryFn = async ( body: { image_names: chunk, board_id }, }); if (response.error) { + if (isAuthChangedError(response.error)) { + // A previous request may already be building a zip for the old session. Do not return its + // item name or raise a rejection toast in the new session. + return { data: undefined as unknown as components['schemas']['ImagesDownloaded'] }; + } if (!scheduled) { // Nothing was scheduled, so this is an ordinary failed request — report it as one and // let the `matchRejected` listener raise the failure toast. @@ -432,13 +450,10 @@ export const imageDTOsByNamesQueryFn = async ( return { error: response.error }; } const chunkDTOs = response.data as ImageDTO[]; - // Checked again after the response, not just before the request: these DTOs were fetched - // as whoever was logged in when the chunk went out, and the store they would be written - // into may since have been reset for someone else (the logout listener clears the api - // state). Publishing them then would seed one user's cache with another's images. - if (isSameAuthContext(authContext)) { - upsertImageDTOs(dispatch, chunkDTOs); - } + // `fetchChunk` checked the context after the response, so these DTOs still belong to the + // session that owns the current cache. A changed session returned above as an error instead + // of leaking stale data through this mutation's fulfilled result. + upsertImageDTOs(dispatch, chunkDTOs); imageDTOs.push(...chunkDTOs); } return { data: imageDTOs }; diff --git a/tests/app/routers/test_images.py b/tests/app/routers/test_images.py index 069f42ea2c7..81b3a9dc901 100644 --- a/tests/app/routers/test_images.py +++ b/tests/app/routers/test_images.py @@ -373,8 +373,9 @@ def delete(image_name: str) -> None: assert response.status_code == 200 body = response.json() - assert body["deleted_images"] == ["ok.png"] - # Absent from both lists: not deleted by us, and not a failure either. + # A concurrent delete satisfies the requested postcondition and must reach the client cleanup + # path as a confirmed deletion. + assert body["deleted_images"] == ["ok.png", "vanished.png"] assert body["failed_images"] == [] From 135191a54977e23a8a2fcd234307ee20b68e2aa2 Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Fri, 21 Aug 2026 07:01:31 -0500 Subject: [PATCH 15/34] test(images): ignore unordered delete response --- tests/app/routers/test_images.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/app/routers/test_images.py b/tests/app/routers/test_images.py index 81b3a9dc901..077adea43bd 100644 --- a/tests/app/routers/test_images.py +++ b/tests/app/routers/test_images.py @@ -374,8 +374,8 @@ def delete(image_name: str) -> None: assert response.status_code == 200 body = response.json() # A concurrent delete satisfies the requested postcondition and must reach the client cleanup - # path as a confirmed deletion. - assert body["deleted_images"] == ["ok.png", "vanished.png"] + # path as a confirmed deletion. The response order is intentionally unspecified. + assert set(body["deleted_images"]) == {"ok.png", "vanished.png"} assert body["failed_images"] == [] From ed50cd30e00d83c388ad8bfa322773b5a777d36c Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Fri, 21 Aug 2026 19:11:20 -0400 Subject: [PATCH 16/34] test(ui): bound the reopen guards, and correct two stale comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two guards added for the retry reopen match through an unbounded `[\s\S]*`, and this file holds two reopens — so the image assertion is satisfied by the *video* reopen further down. Deleting the image reopen, the half that matters most here since image batches are what these routes are about, leaves the suite green. Both are matched adjacently now, and each was checked by deleting its own reopen and confirming only its own test fails. `reportImageBatchOutcome`'s docstring still said a rejection can only mean nothing committed. The auth-changed abort falsifies that: it now returns an error however many chunks landed, deliberately, so the new session cannot consume the old one's aggregate. That is safe because of the session check in the handler itself, which is what the docstring should say. The delete-race comment lost its tie to remove_images_from_board when it stopped skipping, leaving a divergence a reader would be tempted to "fix". Recorded why that route cannot follow: its result list feeds getTagsToInvalidateForImageMutation, so a vanished name would invalidate getImageDTO for a record that is gone and drive a 404 refetch, and it reads the DTO before any authorization check, so answering success there would cover names the caller was never entitled to touch. Co-Authored-By: Claude Opus 5 (1M context) --- invokeai/app/api/routers/images.py | 9 +++++++++ .../components/ChangeBoardModal.test.ts | 13 ++++++++++--- .../web/src/services/api/endpoints/images.ts | 17 ++++++++++------- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/invokeai/app/api/routers/images.py b/invokeai/app/api/routers/images.py index daf9dbdf86f..b4b381b1b11 100644 --- a/invokeai/app/api/routers/images.py +++ b/invokeai/app/api/routers/images.py @@ -557,6 +557,15 @@ def delete_images_from_list( # is, so report the idempotently satisfied postcondition. The client uses # deleted_images to remove stale selections and references. # + # Deliberately unlike remove_images_from_board, which skips the same race. Two + # reasons it cannot copy this. Its result list feeds + # getTagsToInvalidateForImageMutation, so a vanished name there would invalidate + # getImageDTO for a record that no longer exists and drive a 404 refetch — + # getDeleteImagesTags ignores deleted_images precisely to avoid that. And it + # reads the DTO *before* any authorization check, so reporting a 404 as success + # would answer for names the caller was never entitled to touch. Here the + # ownership check has already passed by the time this can be raised. + # # This is narrow only because image_records.get() no longer translates a # sqlite3.Error into this exception — see the comment there. If that # translation ever comes back, a locked or corrupt database would land here diff --git a/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.test.ts b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.test.ts index d2ac8e72b6c..df2ed115e2f 100644 --- a/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.test.ts +++ b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.test.ts @@ -74,13 +74,20 @@ describe('ChangeBoardModal', () => { it('keeps the images that did not move selected', () => { expect(source).toContain('imagesToChangeSelected(failedImageNames)'); - expect(source).toMatch(/imagesToChangeSelected\(failedImageNames\)[\s\S]*isModalOpenChanged\(true\)/); // A rejected request moved nothing at all, so the whole request stays selected. expect(source).toContain('.catch(() => imagesToChange)'); }); - it('reopens with failed videos selected for retry', () => { - expect(source).toMatch(/videosToChangeSelected\(failedVideoNames\)[\s\S]*isModalOpenChanged\(true\)/); + // Both reopens are matched adjacently rather than through an unbounded `[\s\S]*`. This file + // holds two of them, so a gap-matching pattern is satisfied by the *other* one further down: + // deleting the image reopen leaves such an assertion green, which is the half that matters + // most here, image batches being the subject of these routes. + it('reopens with the failed images selected for retry', () => { + expect(source).toMatch(/imagesToChangeSelected\(failedImageNames\)\);\s*dispatch\(isModalOpenChanged\(true\)\);/); + }); + + it('reopens with the failed videos selected for retry', () => { + expect(source).toMatch(/videosToChangeSelected\(failedVideoNames\)\);\s*dispatch\(isModalOpenChanged\(true\)\);/); }); it('captures the operation id before awaiting the move, not after it', () => { diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index e63ca83ff90..68d767d6372 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -287,16 +287,19 @@ export const toastFailedImageBatch = (image_names: string[]) => { * Both branches matter and neither is reachable from anywhere else. `handleDeletions` and its * siblings swallow every outcome, so without the fulfilled branch a run that only partly landed * — server-side per-name failures, or chunks a mid-run failure never reached — says nothing at - * all. And the rejection `buildChunkedImageBatchQueryFn` *returns* is raised only when - * `results.length === 0`, i.e. the first chunk failed and the server committed nothing, so the - * whole argument list is genuinely unapplied and reporting all of it is exact rather than an + * all. And on the ordinary failure path `buildChunkedImageBatchQueryFn` returns an error only + * when `results.length === 0`, i.e. the first chunk failed and the server committed nothing, so + * the whole argument list is genuinely unapplied and reporting all of it is exact rather than an * over-count. Nothing else covers that case: these five endpoints have no `matchRejected` * listener, unlike the single-image board routes. * - * The one way to reach this branch with chunks already committed is for the queryFn to *throw* - * on the mid-run path — `getTags(merged)` and `merged.failed_images.concat(...)` both read keys - * straight off a server payload — which over-counts. That needs a response missing a documented - * key, so it is left as an over-report rather than a silence. + * Two other paths reach this branch with chunks already committed, and the session check is what + * makes both safe to report on. A session change aborts the run as an error however much landed + * — deliberately, so the new session cannot consume the old one's aggregate — and the check + * below is why that does not surface as the previous user's count. The queryFn can also *throw* + * on the mid-run path (`getTags(merged)` and `merged.failed_images.concat(...)` both read keys + * straight off a server payload), which would over-count; that needs a response missing a + * documented key, so it is left as an over-report rather than a silence. * * Extracted rather than repeated inline five times so that the wiring is one unit a test can * hold — an endpoint that swallows its rejection looks identical to one that reports it, and From dbb5297ca8227e12007305809180059ad8fe870b Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Fri, 21 Aug 2026 19:35:29 -0400 Subject: [PATCH 17/34] fix(api): keep an expired session's 401 a failure, not an abort The post-response session check in 806f985 rewrites any response that comes back into a changed session. `dynamicBaseQuery` dispatches sessionExpiredLogout on a 401 *before* it returns and that reducer clears auth_token synchronously, so the session has always "changed" by the time the check runs -- every expired-session 401 came back as an abort instead. That is not a distinction without a difference. An abort is fatal to the whole run by design, so a 401 on chunk 2 of a delete skipped the partial-success path that reports what chunk 1 committed. handleDeletions never saw deleted_images, and nothing else prunes: gallerySlice handles `logout` only, and canvasSlice, nodesSlice and refImagesSlice have no logout handling at all, while all of them are persisted. The canvas, nodes and reference images kept pointing at deleted images across the next login. On the download path it was worse -- the 401 became `{ data: undefined }`, a fulfilled result, so matchRejected never fired and the failure toast was lost outright. Only a successful response is laundered now. An error has no payload to leak into the next session, and rewriting it destroys what it was. Four more from the same pass: - delete_images_from_list reported a name that never existed. assert_image_owner returns immediately for an admin -- the default single-user identity -- without touching storage, so get_dto's own not-found reached the new deleted_images branch. Gated on having actually read the record; a not-found from the read is a skip again, and only the delete losing the race is a deletion. - That branch also dropped affected_boards even though board_id was already bound. getDeleteImagesTags derives every board-scoped tag from it and ignores deleted_images by design, so the board's counts stayed stale for a name reported gone. - imageDTOsByNamesQueryFn's own check was not redundant: fetchChunk is async, so resuming from it is a microtask hop, and a logout landing in that hop passes its check and still clears the cache before the upsert. Restored. - The auto-reopen could show the "select a board" placeholder while still armed for the previous target, since `options` drops the board being viewed. Tests for all of it, plus the gaps the pass proved: dropping the post-response check entirely left 27/28 green, the bulk-download branch could not tell "return nothing" from "leak the old item name", the guard in reportImageBatchOutcome was tested only on the branch an auth change never takes, and the reopen guards accepted a reopen moved above the session check. Each fix was reverted individually and its test confirmed to fail. Co-Authored-By: Claude Opus 5 (1M context) --- invokeai/app/api/routers/images.py | 26 ++++-- .../components/ChangeBoardModal.test.ts | 23 +++++ .../components/ChangeBoardModal.tsx | 6 ++ .../src/services/api/endpoints/images.test.ts | 91 ++++++++++++++++++- .../web/src/services/api/endpoints/images.ts | 26 +++++- tests/app/routers/test_images.py | 45 ++++++++- 6 files changed, 198 insertions(+), 19 deletions(-) diff --git a/invokeai/app/api/routers/images.py b/invokeai/app/api/routers/images.py index b4b381b1b11..2a0a0d78f34 100644 --- a/invokeai/app/api/routers/images.py +++ b/invokeai/app/api/routers/images.py @@ -542,6 +542,13 @@ def delete_images_from_list( # be processed twice, and the second pass's not-found error would land the same # name in both deleted_images and failed_images. for image_name in dict.fromkeys(image_names): + # Bound only once the record has been read, which is what separates the two ways + # this loop can raise ImageRecordNotFoundException. Still None means the read itself + # failed, so nothing here ever established that the record existed — for an admin + # the ownership check is a no-op returning before it touches storage, so a name that + # never existed reaches that raise. Set means the record was read a line earlier and + # only the delete lost the race. + board_id: str | None = None try: _assert_image_owner(image_name, current_user) image_dto = ApiDependencies.invoker.services.images.get_dto(image_name) @@ -552,10 +559,19 @@ def delete_images_from_list( except HTTPException: continue except ImageRecordNotFoundException: + if board_id is None: + # Never read, so there is no postcondition the caller asked for and nothing + # for the client to clean up. A skip, as before. + continue # The record is already gone — a concurrent session deleted it after this - # iteration's ownership check passed. The caller asked for it to be gone and it - # is, so report the idempotently satisfied postcondition. The client uses - # deleted_images to remove stale selections and references. + # iteration read it. The caller asked for it to be gone and it is, so report the + # idempotently satisfied postcondition. The client uses deleted_images to remove + # stale selections and references. The board is reported with it: every + # board-scoped tag getDeleteImagesTags publishes comes from affected_boards, and + # it ignores deleted_images by design, so omitting it leaves the board's counts + # stale while the name is reported gone. + deleted_images.add(image_name) + affected_boards.add(board_id) # # Deliberately unlike remove_images_from_board, which skips the same race. Two # reasons it cannot copy this. Its result list feeds @@ -563,14 +579,12 @@ def delete_images_from_list( # getImageDTO for a record that no longer exists and drive a 404 refetch — # getDeleteImagesTags ignores deleted_images precisely to avoid that. And it # reads the DTO *before* any authorization check, so reporting a 404 as success - # would answer for names the caller was never entitled to touch. Here the - # ownership check has already passed by the time this can be raised. + # would answer for names the caller was never entitled to touch. # # This is narrow only because image_records.get() no longer translates a # sqlite3.Error into this exception — see the comment there. If that # translation ever comes back, a locked or corrupt database would land here # and a whole failed batch would answer 200 with empty result lists. - deleted_images.add(image_name) except Exception: # A genuine deletion failure (not an auth/404 skip) — report it so the # client can surface a partial-failure warning, matching the video path. diff --git a/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.test.ts b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.test.ts index df2ed115e2f..1a88d7f71cc 100644 --- a/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.test.ts +++ b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.test.ts @@ -90,6 +90,29 @@ describe('ChangeBoardModal', () => { expect(source).toMatch(/videosToChangeSelected\(failedVideoNames\)\);\s*dispatch\(isModalOpenChanged\(true\)\);/); }); + it('reopens only on the far side of the guard', () => { + // Adjacency alone does not place them: a reopen moved above `canRetainFailedSelection` keeps + // both patterns above intact while popping the dialog open on a selection that may belong to + // someone else, seeded with the previous session's names. Neither reopen may precede it. + const guard = source.indexOf('canRetainFailedSelection('); + const firstReopen = source.indexOf('isModalOpenChanged(true)'); + + expect(guard).toBeGreaterThan(-1); + expect(firstReopen).toBeGreaterThan(-1); + expect(firstReopen).toBeGreaterThan(guard); + }); + + it('does not carry a hidden board target into the reopened dialog', () => { + // `selectedBoardId` is component state the accept does not reset, and `options` drops the + // board being viewed — so a reopen can show the placeholder while still armed for the old + // target. Cleared before either reopen so the combobox and Move agree. + const clear = source.indexOf('setSelectedBoardId(null)'); + const firstReopen = source.indexOf('isModalOpenChanged(true)'); + + expect(clear).toBeGreaterThan(-1); + expect(clear).toBeLessThan(firstReopen); + }); + it('captures the operation id before awaiting the move, not after it', () => { // The whole ownership check rests on this ordering. Read after the await, the id is // whatever the slice holds once every newer selection has already come and gone, so the diff --git a/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx index d8f0b6b4cd6..3180380afd5 100644 --- a/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx +++ b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx @@ -162,6 +162,12 @@ const ChangeBoardModal = () => { dispatch(changeBoardReset()); return; } + // Cleared before either reopen below. `selectedBoardId` is component state that the accept + // does not reset, and `options` drops whichever board is currently being viewed — so move a + // large selection to B, click into B to watch it arrive, and the dialog reopens showing the + // "select a board" placeholder while still armed for B. Move would then send the retry to a + // target the dialog is not showing. What the combobox displays has to be what Move uses. + setSelectedBoardId(null); // At most one of these fires: the two selections are mutually exclusive by construction — // imagesToChangeSelected clears video_names and videosToChangeSelected clears image_names, // and every caller opens this dialog through one of them. Reopen the dialog so retained diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts index 38ff18e110b..3df1d1393c1 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts @@ -168,6 +168,24 @@ describe('reportImageBatchOutcome', () => { expect(toast).not.toHaveBeenCalled(); }); + it('does not report a rejection from a session that ended while it was pending', async () => { + // The branch the session change actually takes: an auth-changed abort comes back as an + // error, so `queryFulfilled` rejects. Guarding only the fulfilled branch would leave the + // previous user's whole selection toasted at whoever holds the tab next. + login('user-a'); + let rejectQuery: (reason: unknown) => void = () => {}; + const queryFulfilled = new Promise<{ data: { failed_images: string[] } }>((_resolve, reject) => { + rejectQuery = reject; + }); + + const outcome = reportImageBatchOutcome({ image_names: names(3) }, { queryFulfilled }); + switchUser('user-b'); + rejectQuery(new Error('aborted')); + await outcome; + + expect(toast).not.toHaveBeenCalled(); + }); + it('is wired into every chunked batch mutation', () => { // RTK exposes no way to reach an endpoint's `onQueryStarted` at runtime -- the built // endpoint object carries only initiate/select/match*/hooks -- so the wiring is guarded at @@ -313,6 +331,56 @@ describe('buildChunkedImageBatchQueryFn', () => { expect(dispatch).not.toHaveBeenCalled(); }); + it('discards a response that arrived after the session changed', async () => { + // A single-chunk run, so there is no following chunk whose pre-request check could catch + // this: the response itself came back into a session that is no longer the one that asked + // for it. Without the check after the response, its payload is returned and applied. + login('user-a'); + const baseQuery = vi.fn((_args: Request): Promise => { + switchUser('user-b'); + return Promise.resolve({ data: { added_images: ['a.png'], failed_images: [], affected_boards: ['board-1'] } }); + }); + + const { dispatch, result } = run(baseQuery, { image_names: names(5) }); + + expect(baseQuery).toHaveBeenCalledTimes(1); + expect(await result).toEqual({ error: { status: 'CUSTOM_ERROR', error: expect.stringContaining('Aborted') } }); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('reports an expired session as the failure it is, not as an abort', async () => { + // `dynamicBaseQuery` dispatches `sessionExpiredLogout` on a 401 before it returns, and that + // clears the token synchronously — so by the time the post-response check runs the session + // has "changed" for every 401 there is. Rewriting those as aborts would make the ordinary + // expired-session case fatal to the whole run, skipping the partial-success path that + // reports what the server already committed. + login('user-a'); + let call = 0; + const baseQuery = vi.fn((_args: Request): Promise => { + call += 1; + if (call === 2) { + localStorage.removeItem('auth_token'); + return Promise.resolve({ error: { status: 401, data: 'expired' } }); + } + return Promise.resolve({ + data: { added_images: [`chunk-${call}.png`], failed_images: [], affected_boards: ['board-1'] }, + }); + }); + + const { dispatch, result } = run(baseQuery, { image_names: names(2500) }); + + // Chunk 1 committed, so this is a partial success and its unreached names are reported -- + // the same shape a mid-run 500 produces, which is what lets `handleDeletions` prune. + expect(await result).toEqual({ + data: { + added_images: ['chunk-1.png'], + failed_images: names(2500).slice(1000), + affected_boards: ['board-1'], + }, + }); + expect(dispatch).toHaveBeenCalledWith(api.util.invalidateTags(getTags())); + }); + it('keeps running when a login request elsewhere bumps the auth generation', async () => { // `beginAuthTransition` bumps the shared counter when a login or logout request is *sent*, // before anything has changed and whether or not it succeeds — a second tab opening the @@ -471,20 +539,25 @@ describe('bulkDownloadQueryFn', () => { }); it('stops scheduling zips when the session changes mid-run', async () => { + // Switched on the *second* call, so a zip is already scheduled and `first` is set. Switching + // on the first leaves `first` undefined, which makes returning it indistinguishable from + // returning nothing -- and returning it is exactly what must not happen. login('user-a'); let call = 0; const baseQuery = vi.fn((_args: Request): Promise => { call += 1; - switchUser('user-b'); + if (call === 2) { + switchUser('user-b'); + } return Promise.resolve({ data: { bulk_download_item_name: `item-${call}.zip` } }); }); const result = await run(baseQuery, { image_names: names(2500) }); - expect(baseQuery).toHaveBeenCalledTimes(1); + expect(baseQuery).toHaveBeenCalledTimes(2); + // Not `item-1.zip`: that zip belongs to the previous session. Do not expose its item name or + // toast the new session about work it did not request. expect(result).toEqual({ data: undefined }); - // The first response belongs to the previous session. Do not expose its item name or toast - // the new session about work it did not request. expect(toast).not.toHaveBeenCalled(); expect(i18n.t).not.toHaveBeenCalled(); }); @@ -547,6 +620,16 @@ describe('imageDTOsByNamesQueryFn', () => { expect(action.payload.map((entry) => entry.value.image_name)).toEqual(names(1000)); }); + it('checks the session with nothing between the check and the publish', () => { + // Asserted structurally rather than by counting microtasks, which would break on any change + // to the async shape rather than on the property. `fetchChunk` already checks the context + // after the response, but resuming from it is a hop, and a logout landing in that hop passes + // its check and still clears the cache before the upsert runs. Only a check with no await + // between it and the write closes the window, so the write has to sit inside one. + const source = readFileSync(fileURLToPath(new URL('./images.ts', import.meta.url)), 'utf8'); + expect(source).toMatch(/if \(isSameAuthContext\(authContext\)\) \{\s*upsertImageDTOs\(dispatch, chunkDTOs\);\s*\}/); + }); + it('does not publish a chunk that came back after the session changed', async () => { // The DTOs were fetched as whoever was logged in when the chunk went out. By the time they // land the logout listener may have reset the api state for someone else, and writing them diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index 68d767d6372..3b7a16f5ccc 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -109,6 +109,20 @@ const fetchChunk = async ( return { error: AUTH_CHANGED_ERROR }; } const response = await baseQuery(args); + // The check after the response covers *successful* responses only, because a payload is the + // one thing a session that has moved on must not consume. An error carries nothing to + // consume, and rewriting it loses what it was — which matters most for the case that reaches + // here most often. `dynamicBaseQuery` dispatches `sessionExpiredLogout` on a 401 *before* it + // returns, and that reducer clears `auth_token` synchronously, so the token is already gone + // by the time this runs: every expired-session 401 would come back as an abort. That is not + // a distinction without a difference. An abort is deliberately fatal to the whole run, while + // an ordinary chunk failure takes the partial-success path — the one that reports what did + // land, so `handleDeletions` can strip committed deletions out of the canvas, nodes and + // reference images. Those slices are persisted and have no `sessionExpiredLogout` handler at + // all, so a run aborted here leaves them pointing at deleted images across the next login. + if (response.error) { + return response; + } return isSameAuthContext(authContext) ? response : { error: AUTH_CHANGED_ERROR }; }; @@ -453,10 +467,14 @@ export const imageDTOsByNamesQueryFn = async ( return { error: response.error }; } const chunkDTOs = response.data as ImageDTO[]; - // `fetchChunk` checked the context after the response, so these DTOs still belong to the - // session that owns the current cache. A changed session returned above as an error instead - // of leaking stale data through this mutation's fulfilled result. - upsertImageDTOs(dispatch, chunkDTOs); + // Re-checked here as well as inside `fetchChunk`, and not folded into it: `fetchChunk` is + // async, so resuming from it is a microtask hop, and a logout that lands in that hop passes + // its check and still clears the cache before this line. Only a check with no await between + // it and the write closes that. Publishing then would seed one user's cache with another's + // images, into a store the logout listener has just reset. + if (isSameAuthContext(authContext)) { + upsertImageDTOs(dispatch, chunkDTOs); + } imageDTOs.push(...chunkDTOs); } return { data: imageDTOs }; diff --git a/tests/app/routers/test_images.py b/tests/app/routers/test_images.py index 077adea43bd..85761472182 100644 --- a/tests/app/routers/test_images.py +++ b/tests/app/routers/test_images.py @@ -351,7 +351,8 @@ def test_delete_skips_names_deleted_mid_batch( Parametrized over both raise sites because the race window spans them: the loop reads the DTO for its board id and only then deletes, and ImageService.delete re-reads the record, so - a name can vanish after the first read succeeds. + a name can vanish after the first read succeeds. The two sites answer differently on + purpose, and the difference is what this asserts — see the branch below. """ images_service = prepare_image_batch_test(monkeypatch, mock_invoker) @@ -359,7 +360,9 @@ def get_dto(image_name: str) -> MagicMock: if image_name == "vanished.png" and raise_from == "get_dto": raise ImageRecordNotFoundException dto = MagicMock() - dto.board_id = "board-1" + # Distinct boards on purpose: sharing one would let the surviving image supply the + # vanished one's board, and the assertion below could not tell whether it was reported. + dto.board_id = "board-2" if image_name == "vanished.png" else "board-1" return dto def delete(image_name: str) -> None: @@ -373,9 +376,41 @@ def delete(image_name: str) -> None: assert response.status_code == 200 body = response.json() - # A concurrent delete satisfies the requested postcondition and must reach the client cleanup - # path as a confirmed deletion. The response order is intentionally unspecified. - assert set(body["deleted_images"]) == {"ok.png", "vanished.png"} + assert body["failed_images"] == [] + if raise_from == "delete": + # Read a line earlier, so the record verifiably existed and a concurrent session removed + # it: the requested postcondition holds and must reach the client cleanup path as a + # confirmed deletion. Order is intentionally unspecified — the route accumulates a set. + assert set(body["deleted_images"]) == {"ok.png", "vanished.png"} + # Reported with its board. Every board-scoped tag getDeleteImagesTags publishes comes + # from affected_boards, and it ignores deleted_images by design, so dropping the board + # here leaves its counts stale while the name is reported gone. + assert set(body["affected_boards"]) == {"board-1", "board-2"} + else: + # The read itself failed, so nothing established the record ever existed. That matters + # because assert_image_owner returns immediately for an admin — the default single-user + # identity — without touching storage, so a name that never existed reaches this path. + # Reporting it deleted would answer for something the caller never had. + assert body["deleted_images"] == ["ok.png"] + assert set(body["affected_boards"]) == {"board-1"} + + +def test_delete_does_not_report_a_name_that_never_existed( + monkeypatch: Any, mock_invoker: Invoker, client: TestClient +) -> None: + """The admin path specifically: the ownership check is a no-op that proves nothing.""" + images_service = prepare_image_batch_test(monkeypatch, mock_invoker) + + def get_dto(image_name: str) -> MagicMock: + raise ImageRecordNotFoundException + + images_service.get_dto.side_effect = get_dto + + response = client.post("/api/v1/images/delete", json={"image_names": ["never-existed.png"]}) + + assert response.status_code == 200 + body = response.json() + assert body["deleted_images"] == [] assert body["failed_images"] == [] From 3242cdce566b89e01707a05bd5fdf112b69533ed Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Fri, 21 Aug 2026 19:54:06 -0400 Subject: [PATCH 18/34] fix(api): triage a failed session check into expiry vs takeover dbb5297 stopped a chunk's own 401 from being rewritten into an abort, but that closed one door of three. The token does not only vanish via this chunk's own response: a 401 on any concurrent request (a gallery poll, a board refetch) dispatches sessionExpiredLogout, whose reducer clears auth_token synchronously, at whatever await the batch happens to be parked on. Land it between chunks and the next pre-request check hard-aborts; land it while a successful chunk is in flight and the post-response check rewrites that success into an abort. Either way the committed chunks go unreported and the persisted canvas/nodes/ reference-image slices keep their references across the next login -- the same bug, through the doors the last fix did not cover. Reproduced: chunks 1-2 committed, token cleared during chunk 2's flight, run returned a bare abort with no invalidation and no partial payload. The underlying conflation is that isSameAuthContext goes false for two things that need opposite treatment. A takeover (someone else's token now in localStorage) must abort hard: nothing from the old run may be consumed. An expiry (token dropped, no successor) must degrade into an ordinary chunk failure: there is no new session to protect, and the partial-success path is what reports the committed work. So a failed check is now triaged -- SESSION_ENDED_ERROR is not matched by isAuthChangedError and takes the partial path -- and a successful response is consumed unless another user has actually taken over, since a same-user expiry dropping its own committed payload would just un-report work that happened. The expiry test now asserts the partial reporting it exists to protect (it only counted calls before, which both behaviors satisfy), the takeover tests pin the "session changed" wording so the two aborts cannot stand in for each other, and all four collapse directions were reverted individually and fail their tests: expiry-as-hard-abort, takeover-as-soft-stop, post-response aborting on mere expiry, and no post-response check at all. Co-Authored-By: Claude Fable 5 --- .../src/services/api/endpoints/images.test.ts | 44 ++++++++--- .../web/src/services/api/endpoints/images.ts | 79 +++++++++++++------ 2 files changed, 89 insertions(+), 34 deletions(-) diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts index 3df1d1393c1..352493e29f3 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts @@ -321,11 +321,13 @@ describe('buildChunkedImageBatchQueryFn', () => { const { dispatch, result } = run(baseQuery, { image_names: names(2500) }); - // The second response is stale as soon as the session changes, so neither it nor a partial - // aggregate may reach the new session. The server-side write cannot be rolled back here, but - // the next session must refetch its own state rather than consume the old result. + // The second response is stale as soon as another user takes over, so neither it nor a + // partial aggregate may reach the new session. The server-side write cannot be rolled back + // here, but the next session must refetch its own state rather than consume the old result. + // Pinned to the *changed* wording: both abort errors contain "Aborted", and only the + // takeover one may discard committed work like this. expect(await result).toEqual({ - error: { status: 'CUSTOM_ERROR', error: expect.stringContaining('Aborted') }, + error: { status: 'CUSTOM_ERROR', error: expect.stringContaining('session changed') }, }); expect(baseQuery).toHaveBeenCalledTimes(2); expect(dispatch).not.toHaveBeenCalled(); @@ -344,7 +346,9 @@ describe('buildChunkedImageBatchQueryFn', () => { const { dispatch, result } = run(baseQuery, { image_names: names(5) }); expect(baseQuery).toHaveBeenCalledTimes(1); - expect(await result).toEqual({ error: { status: 'CUSTOM_ERROR', error: expect.stringContaining('Aborted') } }); + expect(await result).toEqual({ + error: { status: 'CUSTOM_ERROR', error: expect.stringContaining('session changed') }, + }); expect(dispatch).not.toHaveBeenCalled(); }); @@ -414,20 +418,36 @@ describe('buildChunkedImageBatchQueryFn', () => { expect(baseQuery).toHaveBeenCalledTimes(3); }); - it('stops when the session simply expires mid-run, which bumps no generation', async () => { - // sessionExpiredLogout drops the token with no request of its own -- a 401 anywhere, or a - // token that fails validation on load -- so the generation counter never moves. Without the - // token half of the check, the loop would run on with no credentials at all. + it('stops on expiry but keeps reporting what the run committed', async () => { + // sessionExpiredLogout drops the token with no request of its own -- a 401 on ANY concurrent + // request (a gallery poll, a board refetch) clears it synchronously, and the generation + // counter never moves -- so the loop must stop: without the token half of the check it + // would run on with no credentials at all. But expiry is not a takeover. The committed + // chunk belongs to the very user heading for the login screen, and hard-aborting here + // discards the partial payload that handleDeletions needs to strip committed deletions out + // of the persisted canvas/nodes/reference-image slices -- none of which handle + // sessionExpiredLogout -- so the stale references would survive into their next login. + // Note the token vanishes while a *successful* chunk is in flight: the post-response check + // must consume that payload (same user, work committed), and only then stop. login('user-a'); const baseQuery = vi.fn((_args: Request): Promise => { localStorage.removeItem('auth_token'); - return Promise.resolve({ data: { added_images: [], failed_images: [], affected_boards: [] } }); + return Promise.resolve({ + data: { added_images: ['chunk-1.png'], failed_images: [], affected_boards: ['board-1'] }, + }); }); - const { result } = run(baseQuery, { image_names: names(2500) }); - await result; + const { dispatch, result } = run(baseQuery, { image_names: names(2500) }); + expect(await result).toEqual({ + data: { + added_images: ['chunk-1.png'], + failed_images: names(2500).slice(1000), + affected_boards: ['board-1'], + }, + }); expect(baseQuery).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith(api.util.invalidateTags(getTags())); }); }); diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index 3b7a16f5ccc..6c4039b7ffd 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -76,15 +76,43 @@ type ImagesBaseQuery = ( args: string | FetchArgs ) => QueryReturnValue | PromiseLike>; -/** A stale response must not be consumed by the session that replaced the requester. */ +/** + * Another user's session now owns the tab. This is the hard abort: nothing from the old run may + * be consumed — not a payload, not a partial aggregate, not a toast — because everything it + * carries belongs to whoever started it, and the loops treat it as fatal (`isAuthChangedError`). + */ const AUTH_CHANGED_ERROR: FetchBaseQueryError = { status: 'CUSTOM_ERROR', error: 'Aborted: the authenticated session changed while the operation was running', }; +/** + * The session ended with no successor: the token is gone — a 401 on *any* concurrent request + * dispatches `sessionExpiredLogout`, whose reducer clears `auth_token` synchronously, and a + * deliberate logout does the same — and nobody else has logged in. Deliberately NOT matched by + * `isAuthChangedError`, so it takes the same partial-success path as an ordinary chunk failure: + * there is no new session to protect, the committed chunks belong to the very user who is about + * to land on the login screen, and the partial reporting is what lets `handleDeletions` strip + * committed deletions out of the persisted slices (canvas, nodes, reference images — none of + * which handle `sessionExpiredLogout`) before their next login. + */ +const SESSION_ENDED_ERROR: FetchBaseQueryError = { + status: 'CUSTOM_ERROR', + error: 'Aborted: the authenticated session ended while the operation was running', +}; + const isAuthChangedError = (error: FetchBaseQueryError | undefined): boolean => error?.status === AUTH_CHANGED_ERROR.status && error.error === AUTH_CHANGED_ERROR.error; +/** + * Which of the two a failed session check means. The distinction is the whole ballgame: expiry + * must degrade into an ordinary failure so committed work is still reported, while a takeover + * must abort hard so the new user consumes nothing. Collapsing them in either direction is a + * bug this file has now had both ways. + */ +const sessionMismatchError = (): FetchBaseQueryError => + localStorage.getItem('auth_token') === null ? SESSION_ENDED_ERROR : AUTH_CHANGED_ERROR; + /** * Issues one chunk of a multi-request operation, unless the session it started under is gone. * @@ -98,7 +126,11 @@ const isAuthChangedError = (error: FetchBaseQueryError | undefined): boolean => * So the context is captured before the first chunk, rechecked before each request, and checked * again before its response is consumed. See `isSameAuthContext` for what counts as the same * session — notably a sliding-window token refresh does not, or every long batch would abandon - * itself. + * itself. A failed check is then triaged by `sessionMismatchError`, because the two ways it can + * fail call for opposite treatments — and the token can vanish at *any* await in the run, not + * just this chunk's own request: a 401 on any concurrent request (a gallery poll, a board + * refetch) clears it synchronously mid-flight. Collapse expiry into the hard abort and every + * one of those windows silently discards whatever the earlier chunks already committed. */ const fetchChunk = async ( baseQuery: ImagesBaseQuery, @@ -106,24 +138,24 @@ const fetchChunk = async ( args: FetchArgs ): Promise> => { if (!isSameAuthContext(authContext)) { - return { error: AUTH_CHANGED_ERROR }; + return { error: sessionMismatchError() }; } const response = await baseQuery(args); - // The check after the response covers *successful* responses only, because a payload is the - // one thing a session that has moved on must not consume. An error carries nothing to - // consume, and rewriting it loses what it was — which matters most for the case that reaches - // here most often. `dynamicBaseQuery` dispatches `sessionExpiredLogout` on a 401 *before* it - // returns, and that reducer clears `auth_token` synchronously, so the token is already gone - // by the time this runs: every expired-session 401 would come back as an abort. That is not - // a distinction without a difference. An abort is deliberately fatal to the whole run, while - // an ordinary chunk failure takes the partial-success path — the one that reports what did - // land, so `handleDeletions` can strip committed deletions out of the canvas, nodes and - // reference images. Those slices are persisted and have no `sessionExpiredLogout` handler at - // all, so a run aborted here leaves them pointing at deleted images across the next login. + // An error passes through untriaged: it carries nothing the next session could consume, and + // rewriting it loses what it was. This chunk's own expired-session 401 is the everyday case — + // `dynamicBaseQuery` dispatches `sessionExpiredLogout` before returning it, so the token is + // already gone by this line, and a rewrite would turn the ordinary failure into an abort. if (response.error) { return response; } - return isSameAuthContext(authContext) ? response : { error: AUTH_CHANGED_ERROR }; + // A *successful* payload is consumed unless another user has taken over. Mere expiry keeps + // the payload: it belongs to the same user, the server committed it, and dropping it would + // un-report work that already happened — the loop then stops at the next pre-request check. + const token = localStorage.getItem('auth_token'); + if (token !== null && !isSameAuthContext(authContext)) { + return { error: AUTH_CHANGED_ERROR }; + } + return response; }; export const chunkImageNames = (image_names: string[]): string[][] => { @@ -307,13 +339,16 @@ export const toastFailedImageBatch = (image_names: string[]) => { * over-count. Nothing else covers that case: these five endpoints have no `matchRejected` * listener, unlike the single-image board routes. * - * Two other paths reach this branch with chunks already committed, and the session check is what - * makes both safe to report on. A session change aborts the run as an error however much landed - * — deliberately, so the new session cannot consume the old one's aggregate — and the check - * below is why that does not surface as the previous user's count. The queryFn can also *throw* - * on the mid-run path (`getTags(merged)` and `merged.failed_images.concat(...)` both read keys - * straight off a server payload), which would over-count; that needs a response missing a - * documented key, so it is left as an over-report rather than a silence. + * Three other paths reach this branch with chunks already committed, and the session check is + * what makes each safe. A *takeover* aborts the run as an error however much landed — + * deliberately, so the new session cannot consume the old one's aggregate — and the check below + * is why that does not surface as the previous user's count. An *expiry* mid-run resolves with + * partial data instead (see `SESSION_ENDED_ERROR`), so it lands on the fulfilled branch, where + * the same check keeps the count off the login screen while the queryFn's own invalidation and + * `handleDeletions` still do the state work. And the queryFn can *throw* on the mid-run path + * (`getTags(merged)` and `merged.failed_images.concat(...)` both read keys straight off a + * server payload), which would over-count; that needs a response missing a documented key, so + * it is left as an over-report rather than a silence. * * Extracted rather than repeated inline five times so that the wiring is one unit a test can * hold — an endpoint that swallows its rejection looks identical to one that reports it, and From 6fe2c2d954fdad66cddf95c59bce3b12dcd60d00 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Fri, 21 Aug 2026 20:10:49 -0400 Subject: [PATCH 19/34] fix(ui): silence downloads on expiry too, and pin the untested triage arm An adversarial pass on the triage commit proved its takeover arm untestable from the loops and untested outright: collapsing sessionMismatchError to always-soft left all 32 tests green, despite the previous commit message claiming otherwise. The claim was true only of collapsing the triage and the post-response check together. No loop-level test can do better -- same-tab, everything from one chunk's post-response check to the next's pre-request check is a single synchronous drain, so a takeover staged inside a mocked baseQuery is always caught by the post-response check first. The arm is live only cross-tab, where another tab's login writes localStorage between chunks, and it is the guard that stops the next chunk going out as the new user. So the triage is exported and unit-tested directly on both arms, with the reachability argument recorded on the function. The same pass caught bulk downloads applying the opposite policy to expiry: SESSION_ENDED fell onto the ordinary reporting branches, toasting the failure count at the login screen and -- via the fulfilled item name -- raising the duration:null "preparing" toast there, dismissable only by a socket event the dying session never receives. Downloads now report nothing under either mismatch flavor. The asymmetry with the mutating loops is deliberate and documented: they let expiry through to the partial path because handleDeletions has pruning to do off the payload; a download has no state work, and both of its outputs are wrong for a session that is ending. Also trims the reportImageBatchOutcome docstring claim that invalidation "does the state work" under expiry -- resetApiState has already emptied the store by then, so it is a no-op and the pruning is the part that matters. All three fixes reverted individually and their tests confirmed to fail, the triage in both collapse directions. Co-Authored-By: Claude Fable 5 --- .../src/services/api/endpoints/images.test.ts | 41 +++++++++++++++++++ .../web/src/services/api/endpoints/images.ts | 33 ++++++++++++--- 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts index 352493e29f3..3d1bd4ccee7 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts @@ -11,6 +11,7 @@ import { imagesApi, mergeImageBatchResults, reportImageBatchOutcome, + sessionMismatchError, toastFailedImageBatch, } from 'services/api/endpoints/images'; import type { ImageDTO } from 'services/api/types'; @@ -115,6 +116,22 @@ describe('toastFailedImageBatch', () => { }); }); +describe('sessionMismatchError', () => { + // Tested directly because no loop-level test can reach the pre-request takeover arm: a + // takeover staged inside a mocked baseQuery is always caught by the post-response check + // first, and same-tab nothing can interleave between one chunk's post-check and the next's + // pre-check. Cross-tab it is the guard that stops the next chunk going out as the new user. + it('classifies a takeover as the hard abort', () => { + login('user-b'); + expect(sessionMismatchError()).toMatchObject({ error: expect.stringContaining('session changed') }); + }); + + it('classifies a bare expiry as the soft stop', () => { + localStorage.removeItem('auth_token'); + expect(sessionMismatchError()).toMatchObject({ error: expect.stringContaining('session ended') }); + }); +}); + describe('reportImageBatchOutcome', () => { beforeEach(() => { vi.mocked(toast).mockClear(); @@ -582,6 +599,30 @@ describe('bulkDownloadQueryFn', () => { expect(i18n.t).not.toHaveBeenCalled(); }); + it('reports nothing when the session merely expires mid-run', async () => { + // Broader silence than the mutating loops, which let expiry through to the partial path: + // they have pruning to do off the payload, a download has none, and both of this route's + // outputs are wrong for a session that is ending. The count would toast at the login + // screen, and returning the item name would raise the permanent `duration: null` + // "preparing" toast there -- dismissed only by a socket event this session never receives. + login('user-a'); + let call = 0; + const baseQuery = vi.fn((_args: Request): Promise => { + call += 1; + if (call === 2) { + localStorage.removeItem('auth_token'); + } + return Promise.resolve({ data: { bulk_download_item_name: `item-${call}.zip` } }); + }); + + const result = await run(baseQuery, { image_names: names(2500) }); + + expect(baseQuery).toHaveBeenCalledTimes(2); + expect(result).toEqual({ data: undefined }); + expect(toast).not.toHaveBeenCalled(); + expect(i18n.t).not.toHaveBeenCalled(); + }); + it('reports an error when the first chunk fails, since nothing was scheduled', async () => { const baseQuery = vi.fn( (_args: Request): Promise => Promise.resolve({ error: { status: 403, data: 'nope' } }) diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index 6c4039b7ffd..5770fa6d96a 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -104,13 +104,26 @@ const SESSION_ENDED_ERROR: FetchBaseQueryError = { const isAuthChangedError = (error: FetchBaseQueryError | undefined): boolean => error?.status === AUTH_CHANGED_ERROR.status && error.error === AUTH_CHANGED_ERROR.error; +/** Either flavor of session mismatch — the "consume nothing from this run" superset. */ +const isSessionMismatchError = (error: FetchBaseQueryError | undefined): boolean => + isAuthChangedError(error) || + (error?.status === SESSION_ENDED_ERROR.status && error.error === SESSION_ENDED_ERROR.error); + /** * Which of the two a failed session check means. The distinction is the whole ballgame: expiry * must degrade into an ordinary failure so committed work is still reported, while a takeover * must abort hard so the new user consumes nothing. Collapsing them in either direction is a * bug this file has now had both ways. + * + * Exported for its unit test, and the test exists because no loop-level test can reach the + * takeover arm at the pre-request check: same-tab, everything from one chunk's post-response + * check to the next chunk's pre-request check is a single synchronous drain no dispatch can + * interleave with, so a takeover staged inside a mocked baseQuery is always caught by the + * post-response check first. The pre-request arm is live only cross-tab — another tab's + * login-as-B writes localStorage between chunks — which is real concurrency a same-thread test + * cannot stage, and it is the guard that stops the next chunk going out under B's token. */ -const sessionMismatchError = (): FetchBaseQueryError => +export const sessionMismatchError = (): FetchBaseQueryError => localStorage.getItem('auth_token') === null ? SESSION_ENDED_ERROR : AUTH_CHANGED_ERROR; /** @@ -344,8 +357,10 @@ export const toastFailedImageBatch = (image_names: string[]) => { * deliberately, so the new session cannot consume the old one's aggregate — and the check below * is why that does not surface as the previous user's count. An *expiry* mid-run resolves with * partial data instead (see `SESSION_ENDED_ERROR`), so it lands on the fulfilled branch, where - * the same check keeps the count off the login screen while the queryFn's own invalidation and - * `handleDeletions` still do the state work. And the queryFn can *throw* on the mid-run path + * the same check keeps the count off the login screen while `handleDeletions` still does the + * state work off the partial payload. (The queryFn's own `invalidateTags` also runs, but under + * a same-tab expiry `resetApiState` has already emptied the store by then, so it is a no-op — + * the pruning is the part that matters.) And the queryFn can *throw* on the mid-run path * (`getTags(merged)` and `merged.failed_images.concat(...)` both read keys straight off a * server payload), which would over-count; that needs a response missing a documented key, so * it is left as an over-report rather than a silence. @@ -438,9 +453,15 @@ export const bulkDownloadQueryFn = async ( body: { image_names: chunk, board_id }, }); if (response.error) { - if (isAuthChangedError(response.error)) { - // A previous request may already be building a zip for the old session. Do not return its - // item name or raise a rejection toast in the new session. + if (isSessionMismatchError(response.error)) { + // Either mismatch flavor reports nothing here — broader than the mutating loops, which + // let expiry through to the partial path. They have state work to salvage + // (`handleDeletions` pruning off the partial payload); a download has none, and its two + // outputs are both wrong for a session that is ending. The failure count would toast at + // the login screen, and returning `first` drives `matchFulfilled` into raising the + // keyless-be-damned "preparing" toast with `duration: null` — dismissed only by a + // socket event this session will never receive. A takeover must stay silent for the + // new user's sake; an expiry, for the login screen's. return { data: undefined as unknown as components['schemas']['ImagesDownloaded'] }; } if (!scheduled) { From eb89b1c0645b4bb733936e468fee134fb0b5723f Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sat, 22 Aug 2026 10:37:33 -0400 Subject: [PATCH 20/34] fix(ui): clear the workspace slices and their undo stacks on account change Round-6 review blocker: a cross-user takeover mid-batch hard-aborts the run -- correctly, the new session may consume nothing of the old one's -- but that means handleDeletions never prunes what the committed chunks deleted, and the persisted canvas, nodes and reference-image slices had no account-change handling at all. The next account inherited the previous user's workspace wholesale, stale references to irreversibly deleted images included. The fix is at the account boundary, not in the abort: canvasSlice, nodesSlice and refImagesSlice now reset on `logout`, exactly as gallerySlice and paramsSlice already do. That covers both account-change paths in one place, because accountAwareRootReducer already funnels a cross-tab foreign-token adoption through a synthetic logout() reducer pass. sessionExpiredLogout is deliberately not handled -- a timeout must not destroy unsaved work, and under expiry the batch loops resolve with partial data precisely so handleDeletions can prune the same user's deleted references. The reset alone is not enough for the two undoable slices: their filters keep cross-slice actions out of history without emptying it, so the previous account's states stay one ctrl+Z away. accountAwareRootReducer therefore chains the history clears onto any logout pass -- canvasClearHistory for canvas's overridden clearHistoryType, redux-undo's default clear for nodes and any undoable slice added later. Chained in the reducer rather than the logout listener because the synthetic adoption pass never reaches listeners. Tests assert present-state resets, empty undo stacks (directly -- with few seeded actions an undo's target can coincide with the initial state, so the behavioral check alone cannot see a missing clear), survival across mere expiry and same-user token refresh, and both change paths. Also adds the reviewer-suggested drift check pinning IMAGE_BATCH_CHUNK_SIZE to the maxItems bound the server publishes in openapi.json. Every piece was knocked out individually and its test confirmed to fail, including wiping on expiry. Co-Authored-By: Claude Fable 5 --- .../frontend/web/src/app/store/store.test.ts | 72 +++++++++++++++++++ invokeai/frontend/web/src/app/store/store.ts | 22 ++++-- .../controlLayers/store/canvasSlice.ts | 13 ++++ .../controlLayers/store/refImagesSlice.ts | 8 +++ .../src/features/nodes/store/nodesSlice.ts | 10 +++ .../src/services/api/endpoints/images.test.ts | 25 +++++++ 6 files changed, 146 insertions(+), 4 deletions(-) diff --git a/invokeai/frontend/web/src/app/store/store.test.ts b/invokeai/frontend/web/src/app/store/store.test.ts index efbf5f8faef..213781b9e2d 100644 --- a/invokeai/frontend/web/src/app/store/store.test.ts +++ b/invokeai/frontend/web/src/app/store/store.test.ts @@ -2,9 +2,12 @@ import { Buffer } from 'node:buffer'; import { externalTokenAdopted, logout, sessionExpiredLogout, setCredentials } from 'features/auth/store/authSlice'; import { isModalOpenChanged, videosToChangeSelected } from 'features/changeBoardModal/store/slice'; +import { bboxHeightChanged, bboxWidthChanged, canvasUndo } from 'features/controlLayers/store/canvasSlice'; import { positivePromptChanged } from 'features/controlLayers/store/paramsSlice'; +import { refImageAdded } from 'features/controlLayers/store/refImagesSlice'; import { deleteVideosWithDialog } from 'features/deleteVideoModal/store/state'; import { autoAddBoardIdChanged, boardIdSelected, selectionChanged } from 'features/gallery/store/gallerySlice'; +import { undo as nodesUndo, workflowNameChanged } from 'features/nodes/store/nodesSlice'; import { appInfoApi } from 'services/api/endpoints/appInfo'; import type { S } from 'services/api/types'; import { describe, expect, it } from 'vitest'; @@ -87,6 +90,75 @@ describe('auth cache isolation', () => { expect(store.getState().params.positivePrompt).toBe(''); }); + it.each([ + ['a deliberate logout', () => logout()], + ['a cross-tab account switch', () => externalTokenAdopted(tokenFor('other-user'))], + ])('clears the workspace slices and their undo stacks on %s', (_label, makeAction) => { + // Both account-change paths — a same-tab logout and another tab's foreign token — must + // wipe the workspace: it is personal state, and it is where deleted-image references live + // (raster/control layers, node image fields, reference images), so a cross-user batch + // delete aborted mid-run must not leave the next account holding references to images + // that no longer exist. The undo assertions are the half that is easy to lose: the + // undoable filters keep cross-slice actions out of history without emptying it, so a + // reset alone leaves the previous account's states one ctrl+Z away. The adoption case + // must work at the reducer level — the synthetic logout it triggers never reaches + // listeners, so a listener-based clear would pass the logout case and fail this one. + const store = createStore(); + store.dispatch(setCredentials({ token: tokenFor(user.user_id), user })); + const initialCanvas = store.getState().canvas.present; + const initialNodes = store.getState().nodes.present; + // Alternating types on purpose: consecutive same-type canvas actions engage the undo + // filter's rapid-action throttle, whose reset timer needs `window` — absent in this node + // test environment. Alternation also holds across it.each cases, which share that + // module-level throttle state. + store.dispatch(bboxWidthChanged({ width: initialCanvas.bbox.rect.width + 64 })); + store.dispatch(bboxHeightChanged({ height: initialCanvas.bbox.rect.height + 64 })); + store.dispatch(workflowNameChanged('previous user workflow')); + store.dispatch(refImageAdded()); + expect(store.getState().canvas.present).not.toEqual(initialCanvas); + expect(store.getState().nodes.present).not.toEqual(initialNodes); + expect(store.getState().refImages.entities).toHaveLength(1); + + store.dispatch(makeAction()); + + expect(store.getState().canvas.present).toEqual(initialCanvas); + expect(store.getState().nodes.present).toEqual(initialNodes); + expect(store.getState().refImages.entities).toHaveLength(0); + // The stacks are asserted directly as well as behaviorally: with few seeded actions an + // undo's target can coincide with the initial state, and the behavioral check alone would + // stay green with the clear missing. + expect(store.getState().canvas.past).toHaveLength(0); + expect(store.getState().nodes.past).toHaveLength(0); + store.dispatch(canvasUndo()); + store.dispatch(nodesUndo()); + expect(store.getState().canvas.present).toEqual(initialCanvas); + expect(store.getState().nodes.present).toEqual(initialNodes); + }); + + it('keeps the workspace when the session merely expires', () => { + // A timeout is not an account change: the same user is coming back, and wiping hours of + // canvas or workflow work over it would be destructive. Deleted-image references under + // expiry are handled elsewhere — the batch loops resolve with partial data on expiry + // precisely so `handleDeletions` can prune them. + const store = createStore(); + store.dispatch(setCredentials({ token: tokenFor(user.user_id), user })); + store.dispatch(workflowNameChanged('my unsaved workflow')); + + store.dispatch(sessionExpiredLogout()); + + expect(store.getState().nodes.present.name).toBe('my unsaved workflow'); + }); + + it('keeps the workspace when another tab refreshes the same user token', () => { + const store = createStore(); + store.dispatch(setCredentials({ token: tokenFor(user.user_id), user })); + store.dispatch(workflowNameChanged('my unsaved workflow')); + + store.dispatch(externalTokenAdopted(tokenFor(user.user_id))); + + expect(store.getState().nodes.present.name).toBe('my unsaved workflow'); + }); + it.each([ ['logout', logout], ['session expiry', sessionExpiredLogout], diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts index 10ea0419c97..dacb222d1b0 100644 --- a/invokeai/frontend/web/src/app/store/store.ts +++ b/invokeai/frontend/web/src/app/store/store.ts @@ -35,7 +35,7 @@ import { } from 'features/auth/store/authSlice'; import { changeBoardModalSliceConfig, changeBoardOperationInvalidated } from 'features/changeBoardModal/store/slice'; import { canvasSettingsSliceConfig } from 'features/controlLayers/store/canvasSettingsSlice'; -import { canvasSliceConfig } from 'features/controlLayers/store/canvasSlice'; +import { canvasClearHistory, canvasSliceConfig } from 'features/controlLayers/store/canvasSlice'; import { canvasSessionSliceConfig } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { canvasTextSliceConfig } from 'features/controlLayers/store/canvasTextSlice'; import { canvasWorkflowIntegrationSliceConfig } from 'features/controlLayers/store/canvasWorkflowIntegrationSlice'; @@ -59,7 +59,7 @@ import { uiSliceConfig } from 'features/ui/store/uiSlice'; import { diff } from 'jsondiffpatch'; import type { SerializeFunction, UnserializeFunction } from 'redux-remember'; import { REMEMBER_REHYDRATED, rememberEnhancer, rememberReducer } from 'redux-remember'; -import undoable, { newHistory } from 'redux-undo'; +import undoable, { ActionCreators, newHistory } from 'redux-undo'; import { serializeError } from 'serialize-error'; import { api } from 'services/api'; import type { JsonObject } from 'type-fest'; @@ -146,10 +146,24 @@ const rootReducer = combineReducers(ALL_REDUCERS); type RootReducerState = ReturnType; const accountAwareRootReducer = (state: RootReducerState | undefined, action: UnknownAction): RootReducerState => { - if (state && externalTokenAdopted.match(action) && !tokensBelongToSameUser(state.auth.token, action.payload)) { + const isForeignAdoption = + !!state && externalTokenAdopted.match(action) && !tokensBelongToSameUser(state.auth.token, action.payload); + if (isForeignAdoption) { state = rootReducer(state, logout()); } - return rootReducer(state, action); + state = rootReducer(state, action); + // A logout pass resets each workspace slice's *present* through its own `logout` case, but + // both undoable slices filter cross-slice actions out of history without emptying it — the + // stacks keep the previous account's states, and ctrl+Z would walk the next account straight + // back into them. The clears ride the same reducer call rather than the logout listener + // because the synthetic pass above (cross-tab adoption) never reaches listeners; this is the + // only spot both paths share. Two actions because canvas overrides `clearHistoryType` while + // nodes uses redux-undo's default — which also covers any undoable slice added later. + if (isForeignAdoption || logout.match(action)) { + state = rootReducer(state, canvasClearHistory()); + state = rootReducer(state, ActionCreators.clearHistory()); + } + return state; }; const rememberedRootReducer = rememberReducer(accountAwareRootReducer); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index c2c9b3a858b..d2cf81e97d6 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -5,6 +5,7 @@ import { moveOneToEnd, moveOneToStart, moveToEnd, moveToStart } from 'common/uti import { deepClone } from 'common/util/deepClone'; import { roundDownToMultiple, roundToMultiple } from 'common/util/roundDownToMultiple'; import { merge } from 'es-toolkit/compat'; +import { logout } from 'features/auth/store/authSlice'; import { getPrefixedId } from 'features/controlLayers/konva/util'; import { canvasReset } from 'features/controlLayers/store/actions'; import { aspectRatioIdChanged, modelChanged, resolutionPresetSelected } from 'features/controlLayers/store/paramsSlice'; @@ -1833,6 +1834,18 @@ const slice = createSlice({ builder.addCase(canvasReset, (state) => { return resetState(state); }); + // A deliberate sign-out hands this browser to whoever comes next: the canvas is personal + // workspace state, and it is also where deleted-image references live (raster layers, + // control layers), so leaving it standing hands the next account both the previous user's + // work and, after an aborted cross-user batch delete, references to images that no longer + // exist. `sessionExpiredLogout` is deliberately NOT handled — a session timeout must not + // destroy work, and the same user's committed deletions are pruned by `handleDeletions` + // off the batch's partial result instead. The undo stack is cleared separately: this case + // is a cross-slice action the undoable filter keeps out of history without emptying it, + // and the store's account-change reducer chains `canvasClearHistory` for it. + builder.addCase(logout, (state) => { + return resetState(state); + }); builder.addCase(modelChanged, (state, action) => { const { model } = action.payload; /** diff --git a/invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.ts index 6c364e51e88..114ea629956 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.ts @@ -5,6 +5,7 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import type { RootState } from 'app/store/store'; import type { SliceConfig } from 'app/store/types'; import { clamp } from 'es-toolkit/compat'; +import { logout } from 'features/auth/store/authSlice'; import { getPrefixedId } from 'features/controlLayers/konva/util'; import type { CroppableImageWithDims, @@ -309,6 +310,13 @@ const slice = createSlice({ state.entities = next; }, }, + extraReducers(builder) { + // See canvasSlice: reference images are both personal workspace state and a place + // deleted-image references live; a deliberate sign-out clears them for the next account. + // `sessionExpiredLogout` is deliberately not handled. Not undoable, so the reset alone is + // the whole job here. + builder.addCase(logout, () => getInitialRefImagesState()); + }, }); export const { diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts index 25fdb606a90..3faabbce8f0 100644 --- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts @@ -14,6 +14,7 @@ import { applyEdgeChanges, applyNodeChanges, getConnectedEdges, getIncomers, get import type { SliceConfig } from 'app/store/types'; import { deepClone } from 'common/util/deepClone'; import { isPlainObject } from 'es-toolkit'; +import { logout } from 'features/auth/store/authSlice'; import { addElement, removeElement, @@ -801,6 +802,15 @@ const slice = createSlice({ undo: (state) => state, redo: (state) => state, }, + extraReducers(builder) { + // See canvasSlice: a deliberate sign-out clears personal workspace state for whoever uses + // this browser next, and node image fields are one of the places deleted-image references + // live. `sessionExpiredLogout` is deliberately not handled — a timeout must not destroy an + // unsaved workflow. History: this slice sets no `clearHistoryType`, so redux-undo's default + // clear-history action empties the stack; the store's account-change reducer dispatches it + // alongside this reset. + builder.addCase(logout, () => getInitialState()); + }, }); export const { diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts index 3d1bd4ccee7..d4551521fb0 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts @@ -25,6 +25,31 @@ vi.mock('i18next', () => ({ default: { t: vi.fn((key: string) => key) } })); /** Mirrors MAX_IMAGE_BATCH_SIZE in invokeai/app/api/routers/images.py. */ const CHUNK_SIZE = 1000; +describe('IMAGE_BATCH_CHUNK_SIZE', () => { + it('matches the bound the server actually publishes', () => { + // The client-side chunk size exists only to satisfy the server-side cap, and the two are + // declared in different languages. openapi.json is the committed contract between them + // (typegen-checks keeps it in sync with the routers), so drift on either side fails here + // rather than at runtime as a batch of 422s. + const openapi = JSON.parse( + readFileSync(fileURLToPath(new URL('../../../../openapi.json', import.meta.url)), 'utf8') + ) as { + components: { schemas: Record }; + }; + const bounds = Object.entries(openapi.components.schemas) + .map(([name, schema]) => [name, schema.properties?.image_names?.maxItems] as const) + .filter((entry): entry is [string, number] => entry[1] !== undefined); + + // Every image_names body the server bounds, bounded by the same number — including the five + // batch mutations and the download body. An empty list would mean the server stopped + // publishing the cap, which this must notice rather than vacuously pass. + expect(bounds.length).toBeGreaterThanOrEqual(5); + for (const [name, maxItems] of bounds) { + expect(maxItems, name).toBe(CHUNK_SIZE); + } + }); +}); + const names = (count: number) => Array.from({ length: count }, (_, i) => `image-${i}.png`); /** From acc69820629ab3badfc4c38fc14d8584f0f7a15f Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sat, 22 Aug 2026 10:56:26 -0400 Subject: [PATCH 21/34] fix(ui): purge upscale too, spare the mode switch, and read anyOf bounds An adversarial pass on the account-change purge found the two holes that matter and four test gaps. The purge covered three of the four slices the codebase itself tracks image references in: getImageUsage enumerates nodes, canvas, refImages AND upscale.upscaleInitialImage, which is persisted and had no logout handling -- and which handleDeletions does not prune either, so the takeover scenario had no cleanup path for it at all. upscaleSlice now resets on logout with the other three. Worse, the wipe escalated a dispatch that is not an account change. ProtectedRoute dispatches logout() when the app boots with a leftover multiuser token while the server reports single-user mode -- a mode switch that keeps the same human at the machine. The new logout cases turned that into a workspace wipe, and single-user mode accepts the unauthenticated persist, so 300ms later redux-remember overwrote the stored canvas, workflow and reference images for good. The mode switch now dispatches a dedicated staleCredentialsDiscarded action: same credential clearing, same api-cache reset (what is cached was fetched under multiuser visibility scoping), no workspace wipe, no history clears. logout() belongs to UserMenu alone, and a source guard pins ProtectedRoute to the new action -- reverting it is invisible to every store-level test. Test gaps, each proven by a mutant the old suite accepted: - The openapi drift check read only properties.image_names.maxItems, but the download body is nullable and carries its bound inside anyOf -- mutating that one cap in openapi.json passed. The extractor now recurses into anyOf and the schema-count floor is 7, so the anyOf handling regressing back to the flat six also fails. - Expiry survival was pinned only for nodes; wiping the canvas on sessionExpiredLogout passed all tests. The keep-the-workspace test now covers all four slices and runs for expiry, same-user token refresh, and the mode-switch discard. - Nothing pinned the clears to run AFTER the reset pass: clears moved before it passed the suite, yet leave the filtered reset as _latestUnfiltered, so the next account's first action pushes the previous account's state into past and one ctrl+Z resurrects it. The account-change test now dispatches as the new account, undoes, and asserts the reset comes back. Every fix reverted individually and its test confirmed to fail. Co-Authored-By: Claude Fable 5 --- .../frontend/web/src/app/store/store.test.ts | 90 +++++++++++++++---- invokeai/frontend/web/src/app/store/store.ts | 3 +- .../auth/components/ProtectedRoute.tsx | 14 ++- .../web/src/features/auth/store/authSlice.ts | 21 +++++ .../features/parameters/store/upscaleSlice.ts | 7 ++ .../src/services/api/endpoints/images.test.ts | 19 ++-- 6 files changed, 127 insertions(+), 27 deletions(-) diff --git a/invokeai/frontend/web/src/app/store/store.test.ts b/invokeai/frontend/web/src/app/store/store.test.ts index 213781b9e2d..4e7ea3c00db 100644 --- a/invokeai/frontend/web/src/app/store/store.test.ts +++ b/invokeai/frontend/web/src/app/store/store.test.ts @@ -1,6 +1,14 @@ import { Buffer } from 'node:buffer'; - -import { externalTokenAdopted, logout, sessionExpiredLogout, setCredentials } from 'features/auth/store/authSlice'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +import { + externalTokenAdopted, + logout, + sessionExpiredLogout, + setCredentials, + staleCredentialsDiscarded, +} from 'features/auth/store/authSlice'; import { isModalOpenChanged, videosToChangeSelected } from 'features/changeBoardModal/store/slice'; import { bboxHeightChanged, bboxWidthChanged, canvasUndo } from 'features/controlLayers/store/canvasSlice'; import { positivePromptChanged } from 'features/controlLayers/store/paramsSlice'; @@ -8,9 +16,10 @@ import { refImageAdded } from 'features/controlLayers/store/refImagesSlice'; import { deleteVideosWithDialog } from 'features/deleteVideoModal/store/state'; import { autoAddBoardIdChanged, boardIdSelected, selectionChanged } from 'features/gallery/store/gallerySlice'; import { undo as nodesUndo, workflowNameChanged } from 'features/nodes/store/nodesSlice'; +import { upscaleInitialImageChanged } from 'features/parameters/store/upscaleSlice'; import { appInfoApi } from 'services/api/endpoints/appInfo'; import type { S } from 'services/api/types'; -import { describe, expect, it } from 'vitest'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; import { createStore } from './store'; @@ -27,6 +36,12 @@ const user = { is_active: true, }; +// The canvas undo filter throttles rapid same-type actions with a `window.setTimeout` reset +// timer, and this file runs in the node environment. Only that one API is needed. +beforeAll(() => { + vi.stubGlobal('window', { setTimeout }); +}); + const tokenFor = (userId: string) => `header.${Buffer.from(JSON.stringify({ user_id: userId })).toString('base64url')}.signature`; @@ -107,14 +122,11 @@ describe('auth cache isolation', () => { store.dispatch(setCredentials({ token: tokenFor(user.user_id), user })); const initialCanvas = store.getState().canvas.present; const initialNodes = store.getState().nodes.present; - // Alternating types on purpose: consecutive same-type canvas actions engage the undo - // filter's rapid-action throttle, whose reset timer needs `window` — absent in this node - // test environment. Alternation also holds across it.each cases, which share that - // module-level throttle state. store.dispatch(bboxWidthChanged({ width: initialCanvas.bbox.rect.width + 64 })); store.dispatch(bboxHeightChanged({ height: initialCanvas.bbox.rect.height + 64 })); store.dispatch(workflowNameChanged('previous user workflow')); store.dispatch(refImageAdded()); + store.dispatch(upscaleInitialImageChanged({ image_name: 'previous-user.png', width: 64, height: 64 })); expect(store.getState().canvas.present).not.toEqual(initialCanvas); expect(store.getState().nodes.present).not.toEqual(initialNodes); expect(store.getState().refImages.entities).toHaveLength(1); @@ -124,6 +136,7 @@ describe('auth cache isolation', () => { expect(store.getState().canvas.present).toEqual(initialCanvas); expect(store.getState().nodes.present).toEqual(initialNodes); expect(store.getState().refImages.entities).toHaveLength(0); + expect(store.getState().upscale.upscaleInitialImage).toBeNull(); // The stacks are asserted directly as well as behaviorally: with few seeded actions an // undo's target can coincide with the initial state, and the behavioral check alone would // stay green with the clear missing. @@ -133,30 +146,73 @@ describe('auth cache isolation', () => { store.dispatch(nodesUndo()); expect(store.getState().canvas.present).toEqual(initialCanvas); expect(store.getState().nodes.present).toEqual(initialNodes); + // The clears must land *after* the reset pass, and this is the assertion that pins the + // order: clears that run first leave the filtered reset as redux-undo's _latestUnfiltered, + // so the next account's first action pushes the previous account's state into past — one + // action and one ctrl+Z resurrect it. Dispatch as the new account, undo, and the state + // must come back to the *reset*, not to what was seeded above. + store.dispatch(bboxWidthChanged({ width: initialCanvas.bbox.rect.width + 128 })); + store.dispatch(workflowNameChanged('next user workflow')); + store.dispatch(canvasUndo()); + store.dispatch(nodesUndo()); + expect(store.getState().canvas.present).toEqual(initialCanvas); + expect(store.getState().nodes.present).toEqual(initialNodes); }); - it('keeps the workspace when the session merely expires', () => { - // A timeout is not an account change: the same user is coming back, and wiping hours of - // canvas or workflow work over it would be destructive. Deleted-image references under - // expiry are handled elsewhere — the batch loops resolve with partial data on expiry - // precisely so `handleDeletions` can prune them. + it.each([ + ['the session merely expires', () => sessionExpiredLogout()], + ['another tab refreshes the same user token', () => externalTokenAdopted(tokenFor(user.user_id))], + ['stale multiuser credentials are discarded on a single-user switch', () => staleCredentialsDiscarded()], + ])('keeps the whole workspace when %s', (_label, makeAction) => { + // None of these is an account change. A timeout's user is coming back, and wiping hours of + // canvas or workflow work over it would be destructive — deleted-image references under + // expiry are handled by the batch loops resolving with partial data so `handleDeletions` + // can prune. A same-user token refresh changes nothing at all. And the single-user mode + // switch keeps the same human at the machine — worse, in single-user mode the + // unauthenticated persist is accepted, so a wipe there would overwrite the stored + // workspace for good. Asserted across every purged slice, not just one: each slice decides + // independently which actions it resets on, so a single-slice probe cannot see one of the + // others going over-eager. const store = createStore(); store.dispatch(setCredentials({ token: tokenFor(user.user_id), user })); store.dispatch(workflowNameChanged('my unsaved workflow')); + store.dispatch(bboxWidthChanged({ width: 1024 })); + store.dispatch(refImageAdded()); + store.dispatch(upscaleInitialImageChanged({ image_name: 'mine.png', width: 64, height: 64 })); - store.dispatch(sessionExpiredLogout()); + store.dispatch(makeAction()); expect(store.getState().nodes.present.name).toBe('my unsaved workflow'); + expect(store.getState().canvas.present.bbox.rect.width).toBe(1024); + expect(store.getState().refImages.entities).toHaveLength(1); + expect(store.getState().upscale.upscaleInitialImage?.image_name).toBe('mine.png'); }); - it('keeps the workspace when another tab refreshes the same user token', () => { + it('discards stale credentials on a mode switch without the account-change action', () => { + // A source guard in the manner of ChangeBoardModal.test.ts: the store tests above exercise + // the actions, but nothing else pins WHICH action the mode-switch branch dispatches. + // Reverting it to `logout()` re-arms the account-change wipe on a path where the same + // human keeps the machine — and where single-user mode persists the wipe over their stored + // workspace. `logout()` belongs to UserMenu alone. + const source = readFileSync( + fileURLToPath(new URL('../../features/auth/components/ProtectedRoute.tsx', import.meta.url)), + 'utf8' + ); + expect(source).toContain('dispatch(staleCredentialsDiscarded())'); + expect(source).not.toContain('dispatch(logout())'); + }); + + it('still clears credentials and the api cache when stale multiuser credentials are discarded', async () => { const store = createStore(); store.dispatch(setCredentials({ token: tokenFor(user.user_id), user })); - store.dispatch(workflowNameChanged('my unsaved workflow')); + await store.dispatch(appInfoApi.util.upsertQueryData('getRuntimeConfig', undefined, runtimeConfig)); - store.dispatch(externalTokenAdopted(tokenFor(user.user_id))); + store.dispatch(staleCredentialsDiscarded()); - expect(store.getState().nodes.present.name).toBe('my unsaved workflow'); + expect(store.getState().auth.token).toBeNull(); + expect(store.getState().auth.isAuthenticated).toBe(false); + // The cache was fetched under multiuser visibility scoping, so it does not carry over. + expect(appInfoApi.endpoints.getRuntimeConfig.select()(store.getState()).data).toBeUndefined(); }); it.each([ diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts index dacb222d1b0..4398ae4c8d7 100644 --- a/invokeai/frontend/web/src/app/store/store.ts +++ b/invokeai/frontend/web/src/app/store/store.ts @@ -31,6 +31,7 @@ import { externalTokenAdopted, logout, sessionExpiredLogout, + staleCredentialsDiscarded, tokensBelongToSameUser, } from 'features/auth/store/authSlice'; import { changeBoardModalSliceConfig, changeBoardOperationInvalidated } from 'features/changeBoardModal/store/slice'; @@ -299,7 +300,7 @@ export const addAppListener = addListener.withTypes(); const startAppListening = listenerMiddleware.startListening as AppStartListening; startAppListening({ - matcher: isAnyOf(logout, sessionExpiredLogout), + matcher: isAnyOf(logout, sessionExpiredLogout, staleCredentialsDiscarded), effect: (_action, { dispatch }) => { dispatch(api.util.resetApiState()); dispatch(changeBoardOperationInvalidated()); diff --git a/invokeai/frontend/web/src/features/auth/components/ProtectedRoute.tsx b/invokeai/frontend/web/src/features/auth/components/ProtectedRoute.tsx index d8ffaffe3bd..dd0db7d9b0b 100644 --- a/invokeai/frontend/web/src/features/auth/components/ProtectedRoute.tsx +++ b/invokeai/frontend/web/src/features/auth/components/ProtectedRoute.tsx @@ -1,7 +1,12 @@ import { Center, Spinner } from '@invoke-ai/ui-library'; import type { RootState } from 'app/store/store'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { externalTokenAdopted, logout, sessionExpiredLogout, setCredentials } from 'features/auth/store/authSlice'; +import { + externalTokenAdopted, + sessionExpiredLogout, + setCredentials, + staleCredentialsDiscarded, +} from 'features/auth/store/authSlice'; import type { PropsWithChildren } from 'react'; import { memo, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -91,9 +96,12 @@ export const ProtectedRoute = memo(({ children, requireAdmin = false }: PropsWit useEffect(() => { // If multiuser is disabled, allow access without authentication if (!multiuserEnabled) { - // Clear any persisted auth state when switching to single-user mode + // Discard the leftover auth state when switching to single-user mode. Deliberately not + // `logout()`: that is the account-change action, and the workspace slices reset on it — + // a mode switch keeps the same human at the machine, and in single-user mode the wipe + // would persist over their stored canvas and workflows. See staleCredentialsDiscarded. if (isAuthenticated) { - dispatch(logout()); + dispatch(staleCredentialsDiscarded()); } return; } diff --git a/invokeai/frontend/web/src/features/auth/store/authSlice.ts b/invokeai/frontend/web/src/features/auth/store/authSlice.ts index 1daab4a07f2..353b7ab9730 100644 --- a/invokeai/frontend/web/src/features/auth/store/authSlice.ts +++ b/invokeai/frontend/web/src/features/auth/store/authSlice.ts @@ -104,6 +104,26 @@ const authSlice = createSlice({ localStorage.removeItem('auth_token'); } }, + /** + * Discards leftover credentials without the account-change semantics of `logout`. The one + * caller is ProtectedRoute's multiuser-disabled branch: the server has switched to + * single-user mode and a token from the multiuser era is still lying around. That is a mode + * switch, not a hand-off to another person — the same human keeps the machine — so the + * workspace slices, which reset on `logout` to keep one account's canvas and workflow away + * from the next, must not fire. They would not merely flash empty: in single-user mode the + * unauthenticated persist is accepted, so the wipe would overwrite the stored workspace for + * good. The store listener still clears the api cache on this action, since what is cached + * was fetched under multiuser visibility scoping. + */ + staleCredentialsDiscarded: (state) => { + state.token = null; + state.user = null; + state.isAuthenticated = false; + state.sessionExpired = false; + if (typeof window !== 'undefined' && window.localStorage) { + localStorage.removeItem('auth_token'); + } + }, sessionExpiredLogout: (state) => { state.token = null; state.user = null; @@ -126,6 +146,7 @@ export const { currentUserUpdated, logout, sessionExpiredLogout, + staleCredentialsDiscarded, setLoading, } = authSlice.actions; diff --git a/invokeai/frontend/web/src/features/parameters/store/upscaleSlice.ts b/invokeai/frontend/web/src/features/parameters/store/upscaleSlice.ts index 9604e961b0e..56fa207c0b9 100644 --- a/invokeai/frontend/web/src/features/parameters/store/upscaleSlice.ts +++ b/invokeai/frontend/web/src/features/parameters/store/upscaleSlice.ts @@ -3,6 +3,7 @@ import { createSelector, createSlice } from '@reduxjs/toolkit'; import type { RootState } from 'app/store/store'; import type { SliceConfig } from 'app/store/types'; import { isPlainObject } from 'es-toolkit'; +import { logout } from 'features/auth/store/authSlice'; import type { ImageWithDims } from 'features/controlLayers/store/types'; import { zImageWithDims } from 'features/controlLayers/store/types'; import { zModelIdentifierField } from 'features/nodes/types/common'; @@ -80,6 +81,12 @@ const slice = createSlice({ state.tileOverlap = action.payload; }, }, + extraReducers(builder) { + // See canvasSlice: the upscale initial image is the fourth place `getImageUsage` tracks + // image references, and it is personal workspace state besides — a deliberate sign-out + // clears it for the next account. `sessionExpiredLogout` is deliberately not handled. + builder.addCase(logout, () => getInitialState()); + }, }); export const { diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts index d4551521fb0..069e02d69fb 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts @@ -31,19 +31,26 @@ describe('IMAGE_BATCH_CHUNK_SIZE', () => { // declared in different languages. openapi.json is the committed contract between them // (typegen-checks keeps it in sync with the routers), so drift on either side fails here // rather than at runtime as a batch of 422s. + type ArraySchema = { maxItems?: number; anyOf?: ArraySchema[] }; const openapi = JSON.parse( readFileSync(fileURLToPath(new URL('../../../../openapi.json', import.meta.url)), 'utf8') ) as { - components: { schemas: Record }; + components: { schemas: Record }; }; + // A nullable list serializes as anyOf [bounded array, null], so the bound must be dug out + // of the variants as well — read flat, the download body silently drops off this check and + // its cap can drift alone. + const boundOf = (schema: ArraySchema | undefined): number | undefined => + schema?.maxItems ?? schema?.anyOf?.map((variant) => boundOf(variant)).find((bound) => bound !== undefined); const bounds = Object.entries(openapi.components.schemas) - .map(([name, schema]) => [name, schema.properties?.image_names?.maxItems] as const) + .map(([name, schema]) => [name, boundOf(schema.properties?.image_names)] as const) .filter((entry): entry is [string, number] => entry[1] !== undefined); - // Every image_names body the server bounds, bounded by the same number — including the five - // batch mutations and the download body. An empty list would mean the server stopped - // publishing the cap, which this must notice rather than vacuously pass. - expect(bounds.length).toBeGreaterThanOrEqual(5); + // Every image_names body the server bounds, bounded by the same number: the five batch + // mutations, the DTO read, and the nullable download body. The floor keeps this from + // passing vacuously if the server stops publishing the cap — and from quietly shrinking + // back to the flat six if the anyOf handling regresses. + expect(bounds.length).toBeGreaterThanOrEqual(7); for (const [name, maxItems] of bounds) { expect(maxItems, name).toBe(CHUNK_SIZE); } From e81b402ea660f0e467d49005675a232350d4972d Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sat, 22 Aug 2026 20:38:04 -0400 Subject: [PATCH 22/34] fix(api,ui): classify the zero-row remove, fail a revoked destination, say when downloads are lost Round-8 review, three findings, none blockers but all real. The scoped DELETE in the batch remove reported a removal whatever it matched. The scoping (round 3) was what kept an authorization decision from following the image onto a board it was never taken about -- but a zero-row miss then means the image left the board between the read and the write, and reporting it removed invalidates the wrong boards while the client counts the name as done. The service layers now return the row count (the sqlite layer has its own test: a None return silently restores the old behavior at the route, since `None == 0` is False), and a miss is classified by where the image went: moved to another board = failed (the ask is not satisfied, and a retry re-authorizes against the board it actually sits on); concurrently uncategorized = removed (the postcondition holds and the invalidation lets this client's stale view catch up -- safe in removed_images, unlike a deleted name, because the DTO exists and tag refetches succeed); concurrently deleted = skip, matching the route's existing treatment of vanished names, since removed_images would drive a getImageDTO refetch into a 404. The batch add's per-name destination re-check shared an except arm with the per-image skips, so a destination board revoked or deleted mid-batch emptied the rest of the request into a silent 201 -- which the client reads as success and clears the user's selection over. The destination decision now has its own arm: refusals are reported as failed, per name, with the loop continuing so access restored mid-batch lets later names land. A foreign or vanished image stays a skip; the two refusals mean opposite things. Expiry mid-download now says what was lost. Scheduled zips keep building server-side but their completion events fire into a socket the expired session is tearing down, so nothing will ever offer them; withholding the payload (round 6) was right, but the silence read as a download that never came. A finite, dismissible toast now tells the user to re-run after signing in -- scheduled-work-lost expiry only: a takeover stays fully silent for the new user's sake, a first-chunk 401 stays an ordinary rejection for matchRejected, and nothing-scheduled expiry has lost nothing. Queue-and-replay after re-authentication remains the real fix, as a follow-up. Every fix was reverted individually and its test confirmed to fail, including the sqlite row-count return and the destination arm reverted to a skip. Co-Authored-By: Claude Fable 5 --- invokeai/app/api/routers/board_images.py | 48 ++++- .../board_image_records_base.py | 6 +- .../board_image_records_sqlite.py | 6 +- .../board_images/board_images_base.py | 4 +- .../board_images/board_images_default.py | 4 +- invokeai/frontend/web/public/locales/en.json | 1 + .../src/services/api/endpoints/images.test.ts | 37 +++- .../web/src/services/api/endpoints/images.ts | 28 ++- .../routers/test_board_images_batch_races.py | 183 ++++++++++++++++++ .../routers/test_multiuser_authorization.py | 10 +- 10 files changed, 303 insertions(+), 24 deletions(-) create mode 100644 tests/app/routers/test_board_images_batch_races.py diff --git a/invokeai/app/api/routers/board_images.py b/invokeai/app/api/routers/board_images.py index 1c6aa1cfc14..5fa2445f371 100644 --- a/invokeai/app/api/routers/board_images.py +++ b/invokeai/app/api/routers/board_images.py @@ -195,12 +195,29 @@ def add_images_to_board( # Dedup while preserving order — a repeated name would otherwise be processed twice # and could land in both added_images and failed_images. for image_name in dict.fromkeys(image_names): + # The destination decision sits in its own arm because its refusal means the + # opposite of the per-image one. A foreign or vanished *image* is that name's own + # problem — a skip, matching the other batch routes. A revoked or deleted + # *destination* is the whole request's problem: every remaining name meets it too, + # and treating those as skips answers 201 with empty lists, which the client reads + # as success and clears the user's selection over. Still re-decided per name, and + # the loop keeps going rather than aborting: access restored mid-batch lets later + # names land, and every name refused while it was gone is reported as failed. try: # Re-decided per name rather than resting on the check above. Write access to # the target board can be revoked while the batch is running — a board flipped # from Public to Private — and a decision taken once at the top of a 1000-name # request would let a contributor keep writing to it for the rest of the batch. _assert_board_write_access(board_id, current_user) + except HTTPException: + failed_images.add(image_name) + continue + except Exception: + # The helper propagates storage errors precisely so they are not mistaken for + # "no such board"; a name whose destination could not be decided is reported. + failed_images.add(image_name) + continue + try: _assert_image_direct_owner(image_name, current_user) old_board_id = ( ApiDependencies.invoker.services.board_image_records.get_board_for_image(image_name) or "none" @@ -316,14 +333,43 @@ def remove_images_from_board( continue try: - ApiDependencies.invoker.services.board_images.remove_image_from_board( + deleted_rows = ApiDependencies.invoker.services.board_images.remove_image_from_board( image_name=image_name, board_id=old_board_id ) + if deleted_rows == 0: + # The scoped DELETE missed: the image left old_board_id between the read + # above and this write, so the decision taken about that board no longer + # applies — and reporting a removal that did not happen invalidates the + # wrong boards while the client counts the name as done. Classified by + # where the image is now: + current_board_id = ApiDependencies.invoker.services.board_image_records.get_board_for_image( + image_name + ) + if current_board_id is not None: + # Moved to another board: the caller's ask — off every board — is not + # satisfied, and a retry will re-read and re-authorize against the + # board it actually sits on now. + failed_images.add(image_name) + elif _image_record_exists(image_name): + # Concurrently uncategorized by someone else: the postcondition the + # caller asked for holds, so report it as removed — the invalidation + # is what lets this client's view of the old board catch up. Safe to + # put in removed_images, unlike a deleted name: the DTO still exists, + # so the tag-driven refetches succeed. + removed_images.add(image_name) + affected_boards.add("none") + affected_boards.add(old_board_id) + # Else: deleted concurrently — a skip, exactly as the gone-block above + # treats a name that vanished before the loop reached it. Reporting it + # removed would drive a getImageDTO refetch straight into a 404. + continue removed_images.add(image_name) affected_boards.add("none") affected_boards.add(old_board_id) except Exception: # A genuine storage failure, not an auth/404 skip — see add_images_to_board. + # The zero-row classification's own reads land here too: a name whose state + # cannot be decided is reported, never dropped. failed_images.add(image_name) return RemoveImagesFromBoardResult( removed_images=list(removed_images), diff --git a/invokeai/app/services/board_image_records/board_image_records_base.py b/invokeai/app/services/board_image_records/board_image_records_base.py index 34df1986363..34f74868cc1 100644 --- a/invokeai/app/services/board_image_records/board_image_records_base.py +++ b/invokeai/app/services/board_image_records/board_image_records_base.py @@ -21,11 +21,13 @@ def remove_image_from_board( self, image_name: str, board_id: str, - ) -> None: - """Removes an image from the given board. + ) -> int: + """Removes an image from the given board. Returns the number of rows removed. Scoped to a board on purpose: callers authorize against the board the image is on, and that decision must not be applied to a different board if the image moves in between. + Zero rows means the scope did not hold — the image left the board between the caller's + read and this write — and the caller must not report a removal that did not happen. """ pass diff --git a/invokeai/app/services/board_image_records/board_image_records_sqlite.py b/invokeai/app/services/board_image_records/board_image_records_sqlite.py index 92d84305e01..0bb95cfad90 100644 --- a/invokeai/app/services/board_image_records/board_image_records_sqlite.py +++ b/invokeai/app/services/board_image_records/board_image_records_sqlite.py @@ -37,7 +37,7 @@ def remove_image_from_board( self, image_name: str, board_id: str, - ) -> None: + ) -> int: with self._db.transaction() as cursor: # Scoped to the board the caller was authorized against, not just the image. The # routes read the image's board, authorize against *that* board, and only then @@ -50,6 +50,10 @@ def remove_image_from_board( """, (image_name, board_id), ) + # The row count is the only signal that the scope held. Zero rows means the image + # left this board between the caller's read and this write — swallowing that lets + # the route report a removal that did not happen. + return cursor.rowcount def get_images_for_board( self, diff --git a/invokeai/app/services/board_images/board_images_base.py b/invokeai/app/services/board_images/board_images_base.py index 53c6010c02a..e065f96c015 100644 --- a/invokeai/app/services/board_images/board_images_base.py +++ b/invokeai/app/services/board_images/board_images_base.py @@ -21,8 +21,8 @@ def remove_image_from_board( self, image_name: str, board_id: str, - ) -> None: - """Removes an image from the given board.""" + ) -> int: + """Removes an image from the given board. Returns the number of rows removed.""" pass @abstractmethod diff --git a/invokeai/app/services/board_images/board_images_default.py b/invokeai/app/services/board_images/board_images_default.py index 61d2b4269ee..00b7038f7c8 100644 --- a/invokeai/app/services/board_images/board_images_default.py +++ b/invokeai/app/services/board_images/board_images_default.py @@ -22,8 +22,8 @@ def remove_image_from_board( self, image_name: str, board_id: str, - ) -> None: - self.__invoker.services.board_image_records.remove_image_from_board(image_name, board_id) + ) -> int: + return self.__invoker.services.board_image_records.remove_image_from_board(image_name, board_id) def get_all_board_image_names_for_board( self, diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 12701866eba..93127f422b1 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -2054,6 +2054,7 @@ "imageSavingFailed": "Image Saving Failed", "imageUploaded": "Image Uploaded", "imageUploadFailed": "Image Upload Failed", + "downloadsInterrupted": "Your session ended while downloads were being prepared. Run the download again after signing in.", "imagesFailedToDownload": "{{count}} image could not be prepared for download.", "imagesFailedToDownload_other": "{{count}} images could not be prepared for download.", "imagesFailedToUpdate": "{{count}} image could not be updated.", diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts index 069e02d69fb..10375179bc9 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts @@ -631,12 +631,13 @@ describe('bulkDownloadQueryFn', () => { expect(i18n.t).not.toHaveBeenCalled(); }); - it('reports nothing when the session merely expires mid-run', async () => { - // Broader silence than the mutating loops, which let expiry through to the partial path: - // they have pruning to do off the payload, a download has none, and both of this route's - // outputs are wrong for a session that is ending. The count would toast at the login - // screen, and returning the item name would raise the permanent `duration: null` - // "preparing" toast there -- dismissed only by a socket event this session never receives. + it('withholds the payload but says so when the session expires with zips scheduled', async () => { + // The payload is withheld like the takeover case -- the count is a miscount at the login + // screen, and the item name would raise the permanent `duration: null` "preparing" toast + // there. But expiry with zips already scheduled loses real work: they finish server-side + // and their completion events fire into a dying socket, so nothing will ever offer them. + // Until scheduled downloads are replayed after re-auth, the user is told to re-run rather + // than left reading silence as a download that never came. login('user-a'); let call = 0; const baseQuery = vi.fn((_args: Request): Promise => { @@ -651,8 +652,30 @@ describe('bulkDownloadQueryFn', () => { expect(baseQuery).toHaveBeenCalledTimes(2); expect(result).toEqual({ data: undefined }); + expect(toast).toHaveBeenCalledTimes(1); + expect(vi.mocked(toast).mock.calls[0]?.[0]).toMatchObject({ id: 'DOWNLOADS_INTERRUPTED', status: 'warning' }); + // No per-name failure count: that toast id belongs to genuine chunk failures, and its + // number would be wrong here anyway. + expect(i18n.t).not.toHaveBeenCalledWith('toast.imagesFailedToDownload', expect.anything()); + }); + + it('reports a first-chunk 401 as an ordinary failed request, not an interruption', async () => { + // Nothing was scheduled, so nothing is lost — no zip exists or ever will, and the user + // re-runs after signing in. The 401 passes through untriaged (an error carries nothing the + // next session could consume) and rejects the mutation, so the `matchRejected` listener + // raises its own failure toast; the interruption toast is reserved for the case where + // scheduled work is actually being lost. + login('user-a'); + const baseQuery = vi.fn((_args: Request): Promise => { + localStorage.removeItem('auth_token'); + return Promise.resolve({ error: { status: 401, data: 'expired' } }); + }); + + const result = await run(baseQuery, { image_names: names(2500) }); + + expect(baseQuery).toHaveBeenCalledTimes(1); + expect(result).toEqual({ error: { status: 401, data: 'expired' } }); expect(toast).not.toHaveBeenCalled(); - expect(i18n.t).not.toHaveBeenCalled(); }); it('reports an error when the first chunk fails, since nothing was scheduled', async () => { diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index 5770fa6d96a..06780a7f767 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -454,14 +454,28 @@ export const bulkDownloadQueryFn = async ( }); if (response.error) { if (isSessionMismatchError(response.error)) { - // Either mismatch flavor reports nothing here — broader than the mutating loops, which + // Either mismatch flavor withholds the payload — broader than the mutating loops, which // let expiry through to the partial path. They have state work to salvage - // (`handleDeletions` pruning off the partial payload); a download has none, and its two - // outputs are both wrong for a session that is ending. The failure count would toast at - // the login screen, and returning `first` drives `matchFulfilled` into raising the - // keyless-be-damned "preparing" toast with `duration: null` — dismissed only by a - // socket event this session will never receive. A takeover must stay silent for the - // new user's sake; an expiry, for the login screen's. + // (`handleDeletions` pruning off the partial payload); a download has none, and its + // usual outputs are both wrong for a session that is ending: the failure count is a + // miscount at the login screen, and returning `first` drives `matchFulfilled` into + // raising the "preparing" toast with `duration: null` — dismissed only by a socket + // event this session will never receive. + // + // One thing IS lost on expiry and deserves saying so: zips already scheduled keep + // building server-side, but their completion events fire into a socket this session is + // tearing down, so nothing will ever offer them. Until scheduled downloads are queued + // per account and replayed after re-authentication (follow-up), the honest move is a + // plain, finite toast telling the user to re-run — not silence they will read as a + // download that never came. A takeover stays fully silent: the interruption belongs to + // whoever started the download, never to the user who takes the tab over. + if (!isAuthChangedError(response.error) && scheduled) { + toast({ + id: 'DOWNLOADS_INTERRUPTED', + title: i18n.t('gallery.downloadsInterrupted'), + status: 'warning', + }); + } return { data: undefined as unknown as components['schemas']['ImagesDownloaded'] }; } if (!scheduled) { diff --git a/tests/app/routers/test_board_images_batch_races.py b/tests/app/routers/test_board_images_batch_races.py new file mode 100644 index 00000000000..2ea32e60a58 --- /dev/null +++ b/tests/app/routers/test_board_images_batch_races.py @@ -0,0 +1,183 @@ +"""Race handling on the board_images batch routes. + +Two races found in review, both between a batch loop's read and its write: + +- The scoped DELETE in the batch remove can match zero rows when a concurrent session moves the + image between the DTO read and the write. The row count is the only signal the scope held, and + the classification depends on where the image went. +- The per-name destination re-check in the batch add can start refusing mid-batch when the + destination board is revoked or deleted. That refusal is the request's problem, not the + name's: treated as a skip it answers 201 with empty lists, which the client reads as success + and clears the user's selection over. +""" + +from unittest.mock import MagicMock + +import pytest +from fastapi import HTTPException +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 + + +class MockApiDependencies(ApiDependencies): + invoker: Invoker + + def __init__(self, invoker) -> None: + self.invoker = invoker + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +def _install(monkeypatch: pytest.MonkeyPatch, mock_invoker: Invoker) -> None: + mock_deps = MockApiDependencies(mock_invoker) + # Several of these are None on the conftest's mock services; the routes need whole service + # doubles, installed via monkeypatch so they are restored between tests. + for name in ("image_moves", "images", "image_records", "board_images", "board_image_records", "board_records"): + monkeypatch.setattr(mock_invoker.services, name, MagicMock()) + mock_invoker.services.image_moves.is_maintenance_active.return_value = False + monkeypatch.setattr("invokeai.app.api.routers.board_images.ApiDependencies", mock_deps) + monkeypatch.setattr("invokeai.app.api.routers._access.ApiDependencies", mock_deps) + monkeypatch.setattr("invokeai.app.api.routers.image_move_maintenance.ApiDependencies", mock_deps) + monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps) + + +@pytest.mark.parametrize( + ("now_on_board", "record_exists", "expect_removed", "expect_failed", "expect_boards"), + [ + # Moved to another board mid-batch: the ask -- off every board -- is not satisfied, and + # a retry will re-read and re-authorize against the board it actually sits on now. + ("board-q", True, [], ["raced.png"], []), + # Concurrently uncategorized by someone else: the postcondition holds, and reporting it + # removed is what lets this client's stale view of the old board catch up. Safe in + # removed_images, unlike a deleted name: the DTO exists, so tag refetches succeed. + (None, True, ["raced.png"], [], ["none", "board-p"]), + # Deleted concurrently: a skip, matching the route's existing treatment of a name that + # vanished before the loop reached it. removed_images would drive a 404 refetch. + (None, False, [], [], []), + ], +) +def test_remove_classifies_a_zero_row_scoped_delete_by_where_the_image_went( + monkeypatch: pytest.MonkeyPatch, + mock_invoker: Invoker, + client: TestClient, + now_on_board: str | None, + record_exists: bool, + expect_removed: list[str], + expect_failed: list[str], + expect_boards: list[str], +) -> None: + _install(monkeypatch, mock_invoker) + dto = MagicMock() + dto.board_id = "board-p" + monkeypatch.setattr(mock_invoker.services.images, "get_dto", MagicMock(return_value=dto)) + # The default single-user identity is an admin, so the write-access check passes without + # touching board storage -- which is fine: the subject here is the write, not the check. + monkeypatch.setattr(mock_invoker.services.board_images, "remove_image_from_board", MagicMock(return_value=0)) + monkeypatch.setattr( + mock_invoker.services.board_image_records, "get_board_for_image", MagicMock(return_value=now_on_board) + ) + if record_exists: + monkeypatch.setattr(mock_invoker.services.image_records, "get", MagicMock(return_value=MagicMock())) + else: + from invokeai.app.services.image_records.image_records_common import ImageRecordNotFoundException + + monkeypatch.setattr( + mock_invoker.services.image_records, "get", MagicMock(side_effect=ImageRecordNotFoundException) + ) + + response = client.post("/api/v1/board_images/batch/delete", json={"image_names": ["raced.png"]}) + + assert response.status_code == 201 + body = response.json() + assert body["removed_images"] == expect_removed + assert body["failed_images"] == expect_failed + assert set(body["affected_boards"]) == set(expect_boards) + + +def test_remove_still_reports_a_nonzero_scoped_delete_as_removed( + monkeypatch: pytest.MonkeyPatch, mock_invoker: Invoker, client: TestClient +) -> None: + _install(monkeypatch, mock_invoker) + dto = MagicMock() + dto.board_id = "board-p" + monkeypatch.setattr(mock_invoker.services.images, "get_dto", MagicMock(return_value=dto)) + monkeypatch.setattr(mock_invoker.services.board_images, "remove_image_from_board", MagicMock(return_value=1)) + + response = client.post("/api/v1/board_images/batch/delete", json={"image_names": ["ok.png"]}) + + assert response.status_code == 201 + body = response.json() + assert body["removed_images"] == ["ok.png"] + assert body["failed_images"] == [] + assert set(body["affected_boards"]) == {"none", "board-p"} + + +def test_add_reports_names_refused_by_a_destination_revoked_mid_batch( + monkeypatch: pytest.MonkeyPatch, mock_invoker: Invoker, client: TestClient +) -> None: + """A revoked destination fails the remaining names; it must not empty into a silent 201.""" + _install(monkeypatch, mock_invoker) + monkeypatch.setattr(mock_invoker.services.image_records, "get_user_id", MagicMock(return_value="system")) + monkeypatch.setattr(mock_invoker.services.board_image_records, "get_board_for_image", MagicMock(return_value=None)) + monkeypatch.setattr(mock_invoker.services.board_images, "add_image_to_board", MagicMock(return_value=None)) + # Pre-loop check passes, first per-name check passes, then the board flips Private (or is + # deleted): every later re-check refuses. Patched at the route seam because the default + # single-user identity is an admin, for whom the real helper never refuses. + calls = {"n": 0} + + def write_access(board_id: str, current_user: object) -> None: + calls["n"] += 1 + if calls["n"] > 2: + raise HTTPException(status_code=403, detail="Not authorized to modify this board") + + monkeypatch.setattr("invokeai.app.api.routers.board_images._assert_board_write_access", write_access) + + response = client.post( + "/api/v1/board_images/batch", + json={"board_id": "board-x", "image_names": ["first.png", "second.png", "third.png"]}, + ) + + assert response.status_code == 201 + body = response.json() + assert body["added_images"] == ["first.png"] + # The refused names are failures the client can toast and retry -- not skips that leave a + # 201 with empty lists for the UI to read as success. + assert set(body["failed_images"]) == {"second.png", "third.png"} + + +def test_sqlite_remove_returns_the_row_count() -> None: + """The storage layer itself: the row count is the only signal the scoped DELETE's scope + held, and a `None` return silently restores report-a-removal-that-did-not-happen at the + route (`None == 0` is False). Stubbed at the cursor in the manner of the image-records + storage tests.""" + from invokeai.app.services.board_image_records.board_image_records_sqlite import SqliteBoardImageRecordStorage + + storage = SqliteBoardImageRecordStorage.__new__(SqliteBoardImageRecordStorage) + + class _Cursor: + rowcount = 0 + + def execute(self, *args: object, **kwargs: object) -> None: + pass + + class _Db: + def transaction(self): + from contextlib import contextmanager + + @contextmanager + def _cm(): + yield _Cursor() + + return _cm() + + storage._db = _Db() # pyright: ignore[reportAttributeAccessIssue] + + assert storage.remove_image_from_board("raced.png", "board-p") == 0 + _Cursor.rowcount = 1 + assert storage.remove_image_from_board("ok.png", "board-p") == 1 diff --git a/tests/app/routers/test_multiuser_authorization.py b/tests/app/routers/test_multiuser_authorization.py index 46ce8500f56..eeabd674b08 100644 --- a/tests/app/routers/test_multiuser_authorization.py +++ b/tests/app/routers/test_multiuser_authorization.py @@ -590,7 +590,13 @@ def _revoke_after_first_removal(image_name: str, board_id: str) -> None: def test_batch_add_stops_when_board_write_access_is_revoked_mid_batch( self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str ): - """The same window on the add side, where the target board is checked before the loop.""" + """The same window on the add side, where the target board is checked before the loop. + + The refused names are reported as failed, not skipped: the destination is the whole + request's problem, and skipping empties the rest of the batch into a 201 with empty + lists, which the client reads as success and clears the user's selection over. The + loop must still stop *issuing adds* the moment access is gone. + """ from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardVisibility user2 = mock_invoker.services.users.get_by_email("user2@test.com") @@ -616,7 +622,7 @@ def _revoke_after_first_add(board_id: str, image_name: str) -> None: assert r.status_code == status.HTTP_201_CREATED body = r.json() assert body["added_images"] == [names[0]] - assert body["failed_images"] == [] + assert set(body["failed_images"]) == set(names[1:]) assert mock_invoker.services.board_images.add_image_to_board.call_count == 1 def test_batch_add_skips_an_image_deleted_after_its_ownership_check( From 41ed97c792bcf643090cdd2e249e9c2c9355ba82 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sat, 22 Aug 2026 21:01:37 -0400 Subject: [PATCH 23/34] fix(api,ui): repair the interruption toast's key, cover the everyday 401, probe positively An adversarial pass on the round-8 fixes caught the delivered toast not working at all, its everyday trigger missing, a success manufactured from a storage error, and an untested service seam. The interruption toast referenced gallery.downloadsInterrupted while the key sits under toast. -- with returnNull false, i18next renders the raw key string. The test could not see it: the i18n mock echoes keys and the assertion checked only id and status. Reference fixed and the title pinned, which is what makes a wrong section unrepresentable. The toast also only fired when the pre-request check between chunks saw the token gone -- but the everyday expiry arrives as the download chunk's own 401, token already cleared by dynamicBaseQuery, which sailed past the mismatch check into the partial path: a failure-count toast at the login screen plus, via the returned item name, the permanent "preparing" toast. The error branch now also treats any error that came back into a changed session as a session outcome. The mutating loops deliberately do not do this -- their own-401 belongs on the partial path, where the payload feeds handleDeletions -- and the comment says so, so a consistency cleanup cannot quietly reintroduce it. In the zero-row remove classification, the existence probe used _image_record_exists, which answers True on a storage error -- conservative where True means failed (the add loop), but here True meant removed: a transient error manufactured a success whose tag-driven getImageDTO refetch then 404s. The arm now probes image_records.get directly: not-found is the skip, a storage error propagates to the failed arm, and only a record positively known to exist is reported removed. Also: the facade passthrough gets its own test -- CI has no type checker, so a dropped `return` silently restores None == 0 = False at the route while the route tests mock the facade and the sqlite test pins the layer below -- and a name already uncategorized short-circuits before the scoped DELETE, which can never match it, saving the classification's two reads per name. The revoked-destination expectation in test_multiuser_authorization moves to the new failed-not-skipped semantics, exercising them through the real access stack. Every fix reverted individually and its test confirmed to fail. Co-Authored-By: Claude Fable 5 --- invokeai/app/api/routers/board_images.py | 44 ++++++++++++----- .../src/services/api/endpoints/images.test.ts | 48 +++++++++++++++---- .../web/src/services/api/endpoints/images.ts | 29 ++++++----- .../routers/test_board_images_batch_races.py | 42 ++++++++++++++++ 4 files changed, 130 insertions(+), 33 deletions(-) diff --git a/invokeai/app/api/routers/board_images.py b/invokeai/app/api/routers/board_images.py index 5fa2445f371..a769472be01 100644 --- a/invokeai/app/api/routers/board_images.py +++ b/invokeai/app/api/routers/board_images.py @@ -332,6 +332,15 @@ def remove_images_from_board( failed_images.add(image_name) continue + # No board_images row ever carries board_id="none" — uncategorized is the absence + # of a row — so for a name already off every board the scoped DELETE cannot match + # and the zero-row classification below would spend two reads confirming what the + # DTO already said. Same report the classification would produce, minus the reads. + if old_board_id == "none": + removed_images.add(image_name) + affected_boards.add("none") + continue + try: deleted_rows = ApiDependencies.invoker.services.board_images.remove_image_from_board( image_name=image_name, board_id=old_board_id @@ -350,18 +359,29 @@ def remove_images_from_board( # satisfied, and a retry will re-read and re-authorize against the # board it actually sits on now. failed_images.add(image_name) - elif _image_record_exists(image_name): - # Concurrently uncategorized by someone else: the postcondition the - # caller asked for holds, so report it as removed — the invalidation - # is what lets this client's view of the old board catch up. Safe to - # put in removed_images, unlike a deleted name: the DTO still exists, - # so the tag-driven refetches succeed. - removed_images.add(image_name) - affected_boards.add("none") - affected_boards.add(old_board_id) - # Else: deleted concurrently — a skip, exactly as the gone-block above - # treats a name that vanished before the loop reached it. Reporting it - # removed would drive a getImageDTO refetch straight into a 404. + continue + # Probed directly rather than through _image_record_exists: that helper + # answers True on a storage error, which is the conservative bias where + # True means "report as failed" (the add loop) — here True means "report + # as removed", and a transient storage error must not manufacture a + # success whose tag-driven getImageDTO refetch then 404s. A storage error + # propagates to the arm below instead: a name whose state cannot be + # decided is reported as failed, never as done. + try: + ApiDependencies.invoker.services.image_records.get(image_name) + except ImageRecordNotFoundException: + # Deleted concurrently — a skip, exactly as the gone-block above + # treats a name that vanished before the loop reached it. Reporting + # it removed would drive a getImageDTO refetch straight into a 404. + continue + # Concurrently uncategorized by someone else: the postcondition the + # caller asked for holds, so report it as removed — the invalidation is + # what lets this client's view of the old board catch up. Safe in + # removed_images, unlike a deleted name: the DTO exists, so the + # tag-driven refetches succeed. + removed_images.add(image_name) + affected_boards.add("none") + affected_boards.add(old_board_id) continue removed_images.add(image_name) affected_boards.add("none") diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts index 10375179bc9..edfe32e2007 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts @@ -653,18 +653,50 @@ describe('bulkDownloadQueryFn', () => { expect(baseQuery).toHaveBeenCalledTimes(2); expect(result).toEqual({ data: undefined }); expect(toast).toHaveBeenCalledTimes(1); - expect(vi.mocked(toast).mock.calls[0]?.[0]).toMatchObject({ id: 'DOWNLOADS_INTERRUPTED', status: 'warning' }); + // The title pins the full key path. The i18n mock echoes the key, so a reference into the + // wrong section -- the first cut said `gallery.` while the key lives under `toast.` -- + // renders the raw key string in production while an id-only assertion stays green. + expect(vi.mocked(toast).mock.calls[0]?.[0]).toMatchObject({ + id: 'DOWNLOADS_INTERRUPTED', + title: 'toast.downloadsInterrupted', + status: 'warning', + }); // No per-name failure count: that toast id belongs to genuine chunk failures, and its // number would be wrong here anyway. expect(i18n.t).not.toHaveBeenCalledWith('toast.imagesFailedToDownload', expect.anything()); }); - it('reports a first-chunk 401 as an ordinary failed request, not an interruption', async () => { - // Nothing was scheduled, so nothing is lost — no zip exists or ever will, and the user - // re-runs after signing in. The 401 passes through untriaged (an error carries nothing the - // next session could consume) and rejects the mutation, so the `matchRejected` listener - // raises its own failure toast; the interruption toast is reserved for the case where - // scheduled work is actually being lost. + it("treats the chunk's own expired-session 401 as an interruption, not a partial failure", async () => { + // The everyday expiry vector: the 401 arrives on the download's own chunk, and + // dynamicBaseQuery has already cleared the token by the time it returns. The mutating + // loops keep that 401 on the partial path on purpose -- the payload feeds handleDeletions + // -- but here the partial path produces exactly the two wrong toasts: a failure count at + // the login screen and, via the returned item name, the permanent "preparing" toast. + login('user-a'); + let call = 0; + const baseQuery = vi.fn((_args: Request): Promise => { + call += 1; + if (call === 2) { + localStorage.removeItem('auth_token'); + return Promise.resolve({ error: { status: 401, data: 'expired' } }); + } + return Promise.resolve({ data: { bulk_download_item_name: `item-${call}.zip` } }); + }); + + const result = await run(baseQuery, { image_names: names(2500) }); + + expect(baseQuery).toHaveBeenCalledTimes(2); + expect(result).toEqual({ data: undefined }); + expect(toast).toHaveBeenCalledTimes(1); + expect(vi.mocked(toast).mock.calls[0]?.[0]).toMatchObject({ id: 'DOWNLOADS_INTERRUPTED' }); + expect(i18n.t).not.toHaveBeenCalledWith('toast.imagesFailedToDownload', expect.anything()); + }); + + it('stays silent on a first-chunk expired-session 401, which loses nothing', async () => { + // Nothing was scheduled: no zip exists or ever will, and the user re-runs after signing + // in. Withheld like every other session-ending outcome rather than rejected -- a rejection + // would toast "Problem Preparing Download" at the login screen for a download that simply + // needs re-running. login('user-a'); const baseQuery = vi.fn((_args: Request): Promise => { localStorage.removeItem('auth_token'); @@ -674,7 +706,7 @@ describe('bulkDownloadQueryFn', () => { const result = await run(baseQuery, { image_names: names(2500) }); expect(baseQuery).toHaveBeenCalledTimes(1); - expect(result).toEqual({ error: { status: 401, data: 'expired' } }); + expect(result).toEqual({ data: undefined }); expect(toast).not.toHaveBeenCalled(); }); diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index 06780a7f767..81cadd75e1c 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -453,26 +453,29 @@ export const bulkDownloadQueryFn = async ( body: { image_names: chunk, board_id }, }); if (response.error) { - if (isSessionMismatchError(response.error)) { - // Either mismatch flavor withholds the payload — broader than the mutating loops, which - // let expiry through to the partial path. They have state work to salvage - // (`handleDeletions` pruning off the partial payload); a download has none, and its - // usual outputs are both wrong for a session that is ending: the failure count is a - // miscount at the login screen, and returning `first` drives `matchFulfilled` into - // raising the "preparing" toast with `duration: null` — dismissed only by a socket - // event this session will never receive. - // + // The mismatch errors from the pre/post-request checks, AND any error response that came + // back into a session that is no longer the one that asked — which is how this chunk's + // OWN expired-session 401 arrives: `dynamicBaseQuery` dispatches `sessionExpiredLogout` + // before returning it, so the token is already gone. The mutating loops deliberately do + // NOT reclassify that 401 — they need it on the partial path, where the payload feeds + // `handleDeletions` — but a download has no state work to salvage, and its usual outputs + // are both wrong for a session that is ending: the failure count is a miscount at the + // login screen, and returning `first` drives `matchFulfilled` into raising the + // "preparing" toast with `duration: null` — dismissed only by a socket event this + // session will never receive. + if (isSessionMismatchError(response.error) || !isSameAuthContext(authContext)) { // One thing IS lost on expiry and deserves saying so: zips already scheduled keep // building server-side, but their completion events fire into a socket this session is // tearing down, so nothing will ever offer them. Until scheduled downloads are queued // per account and replayed after re-authentication (follow-up), the honest move is a // plain, finite toast telling the user to re-run — not silence they will read as a - // download that never came. A takeover stays fully silent: the interruption belongs to - // whoever started the download, never to the user who takes the tab over. - if (!isAuthChangedError(response.error) && scheduled) { + // download that never came. A takeover (someone else's token in localStorage) stays + // fully silent: the interruption belongs to whoever started the download, never to the + // user who takes the tab over. And an expiry with nothing scheduled has lost nothing. + if (localStorage.getItem('auth_token') === null && scheduled) { toast({ id: 'DOWNLOADS_INTERRUPTED', - title: i18n.t('gallery.downloadsInterrupted'), + title: i18n.t('toast.downloadsInterrupted'), status: 'warning', }); } diff --git a/tests/app/routers/test_board_images_batch_races.py b/tests/app/routers/test_board_images_batch_races.py index 2ea32e60a58..6a3ce1b77e5 100644 --- a/tests/app/routers/test_board_images_batch_races.py +++ b/tests/app/routers/test_board_images_batch_races.py @@ -100,6 +100,48 @@ def test_remove_classifies_a_zero_row_scoped_delete_by_where_the_image_went( assert set(body["affected_boards"]) == set(expect_boards) +def test_remove_reports_a_zero_row_name_whose_existence_cannot_be_decided_as_failed( + monkeypatch: pytest.MonkeyPatch, mock_invoker: Invoker, client: TestClient +) -> None: + """A transient storage error during the existence probe must not manufacture a success: + reported removed, the client's tag-driven getImageDTO refetch 404s if the image was in + fact deleted. A name whose state cannot be decided is reported as failed, never as done.""" + import sqlite3 + + _install(monkeypatch, mock_invoker) + dto = MagicMock() + dto.board_id = "board-p" + monkeypatch.setattr(mock_invoker.services.images, "get_dto", MagicMock(return_value=dto)) + monkeypatch.setattr(mock_invoker.services.board_images, "remove_image_from_board", MagicMock(return_value=0)) + monkeypatch.setattr(mock_invoker.services.board_image_records, "get_board_for_image", MagicMock(return_value=None)) + monkeypatch.setattr( + mock_invoker.services.image_records, + "get", + MagicMock(side_effect=sqlite3.OperationalError("database is locked")), + ) + + response = client.post("/api/v1/board_images/batch/delete", json={"image_names": ["undecidable.png"]}) + + assert response.status_code == 201 + body = response.json() + assert body["removed_images"] == [] + assert body["failed_images"] == ["undecidable.png"] + + +def test_facade_passes_the_row_count_through() -> None: + """The middle layer: CI has no type checker, so a dropped `return` here silently restores + report-a-removal-that-did-not-happen at the route (`None == 0` is False), while the route + tests mock this service and the sqlite test pins the layer below it.""" + from invokeai.app.services.board_images.board_images_default import BoardImagesService + + service = BoardImagesService.__new__(BoardImagesService) + invoker = MagicMock() + invoker.services.board_image_records.remove_image_from_board = MagicMock(return_value=0) + service._BoardImagesService__invoker = invoker # pyright: ignore[reportAttributeAccessIssue] + + assert service.remove_image_from_board("raced.png", "board-p") == 0 + + def test_remove_still_reports_a_nonzero_scoped_delete_as_removed( monkeypatch: pytest.MonkeyPatch, mock_invoker: Invoker, client: TestClient ) -> None: From 1defaf58a1ea98d214c5a85be8b5384f27dcc8fa Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sun, 23 Aug 2026 12:27:37 -0400 Subject: [PATCH 24/34] fix(api,ui): classify the single remove's zero-row race, reconcile lost chunks, close the final-chunk expiry window Three review findings, one per layer of the batch hardening: - board_images.py: the single-image remove ran the same read-then-scoped-DELETE sequence as the batch loop but ignored the row count, so an image that left the board between the read and the write was reported removed anyway. The batch loop's zero-row classification is extracted into _remove_from_board_and_classify and both routes now share it. - images.ts: a chunk failing with a transport-shaped error (FETCH_ERROR, TIMEOUT_ERROR, PARSING_ERROR, 5xx) was treated as proof the chunk applied nothing, leaving caches unreconciled when the server had committed and only the response was lost. Such chunks are still reported failed - retrying a satisfied name is safe - but their tags are now invalidated as if the chunk had landed, via a per-endpoint assumeCommitted result so the tags come from the same getTags the endpoint publishes. - bulkDownloadQueryFn: an expiry during the final chunk's await was caught by no check (fetchChunk deliberately passes mere expiry through, and there is no next iteration's pre-request check), so matchFulfilled raised the undismissible 'preparing' toast while the scheduled zips were lost silently. A post-loop session check now applies the same triage as the in-loop arm. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UE38kzGWoj4w6dYiPqa3CY --- invokeai/app/api/routers/board_images.py | 131 +++++++++++------- .../src/services/api/endpoints/images.test.ts | 114 ++++++++++++++- .../web/src/services/api/endpoints/images.ts | 67 ++++++++- .../routers/test_board_images_batch_races.py | 89 ++++++++++++ .../routers/test_multiuser_authorization.py | 10 +- 5 files changed, 353 insertions(+), 58 deletions(-) diff --git a/invokeai/app/api/routers/board_images.py b/invokeai/app/api/routers/board_images.py index a769472be01..ab0b32cadcd 100644 --- a/invokeai/app/api/routers/board_images.py +++ b/invokeai/app/api/routers/board_images.py @@ -1,3 +1,5 @@ +from enum import Enum, auto + from fastapi import Body, HTTPException from fastapi.routing import APIRouter @@ -64,6 +66,58 @@ def _image_record_exists(image_name: str) -> bool: return True +class _ScopedRemoveOutcome(Enum): + """What a scoped board-image DELETE turned out to have done, judged by its row count.""" + + REMOVED = auto() + """The row was deleted -- or the image was concurrently uncategorized by someone else, in + which case the postcondition the caller asked for (off every board) holds, and reporting it + removed is what lets the client's stale view of the old board catch up. Safe to report, + unlike a deleted name: the DTO exists, so the tag-driven refetches succeed.""" + + MOVED = auto() + """Now on another board: the ask is not satisfied, and a retry will re-read and + re-authorize against the board the image actually sits on now. Report as failed.""" + + GONE = auto() + """Image deleted concurrently: a skip, never a success -- reporting it removed would drive + the client's tag-driven getImageDTO refetch straight into a 404.""" + + +def _remove_from_board_and_classify(image_name: str, old_board_id: str) -> _ScopedRemoveOutcome: + """Runs the scoped DELETE for a name read as sitting on `old_board_id`, then classifies. + + The scoped DELETE misses when the image leaves `old_board_id` between the caller's read + and this write. The row count is the only signal the scope held: ignore it and the route + reports a removal that did not happen, invalidating the wrong boards while the client + counts the name as done. A zero-row miss is classified by where the image is now. + + The existence probe is direct rather than through `_image_record_exists`: that helper + answers True on a storage error, which is the conservative bias where True means "report + as failed" (the add loop) -- here True means "report as removed", and a transient storage + error must not manufacture a success. Storage errors -- the DELETE's own and the + classification reads' -- propagate instead: a name whose state cannot be decided must be + reported by the caller as failed, never as done. + + `old_board_id` must be a real board id: uncategorized is the absence of a row, so a scoped + DELETE for "none" cannot match and the classification would spend two reads confirming + what the caller's DTO read already said. + """ + deleted_rows = ApiDependencies.invoker.services.board_images.remove_image_from_board( + image_name=image_name, board_id=old_board_id + ) + if deleted_rows > 0: + return _ScopedRemoveOutcome.REMOVED + current_board_id = ApiDependencies.invoker.services.board_image_records.get_board_for_image(image_name) + if current_board_id is not None: + return _ScopedRemoveOutcome.MOVED + try: + ApiDependencies.invoker.services.image_records.get(image_name) + except ImageRecordNotFoundException: + return _ScopedRemoveOutcome.GONE + return _ScopedRemoveOutcome.REMOVED + + def _assert_image_direct_owner(image_name: str, current_user: CurrentUserOrDefault) -> None: """Raise 403 if the current user is not the direct owner of the image. @@ -138,17 +192,31 @@ def remove_image_from_board( _assert_board_write_access(old_board_id, current_user) assert_image_move_maintenance_inactive() removed_images: set[str] = set() + failed_images: set[str] = set() affected_boards: set[str] = set() - ApiDependencies.invoker.services.board_images.remove_image_from_board( - image_name=image_name, board_id=old_board_id - ) - removed_images.add(image_name) - affected_boards.add("none") - affected_boards.add(old_board_id) + if old_board_id == "none": + # Already off every board — the postcondition holds without a write. No + # board_images row ever carries board_id="none", so a scoped DELETE could not + # match anyway; see the identical shortcut in the batch loop below. + removed_images.add(image_name) + affected_boards.add("none") + else: + # The same row-count classification the batch loop uses. This route used to + # ignore the count, so an image that left old_board_id between the read above and + # the write was reported removed anyway — a false success that invalidated the + # wrong boards and told the client the name was done. + outcome = _remove_from_board_and_classify(image_name, old_board_id) + if outcome is _ScopedRemoveOutcome.REMOVED: + removed_images.add(image_name) + affected_boards.add("none") + affected_boards.add(old_board_id) + elif outcome is _ScopedRemoveOutcome.MOVED: + failed_images.add(image_name) + # GONE lands in neither list, matching the batch route's treatment of a name that + # vanished mid-flight; the client's refetches surface the deletion. return RemoveImagesFromBoardResult( removed_images=list(removed_images), - # Single-image route: a failure here is a 500, never a partial success. - failed_images=[], + failed_images=list(failed_images), affected_boards=list(affected_boards), ) @@ -342,50 +410,15 @@ def remove_images_from_board( continue try: - deleted_rows = ApiDependencies.invoker.services.board_images.remove_image_from_board( - image_name=image_name, board_id=old_board_id - ) - if deleted_rows == 0: - # The scoped DELETE missed: the image left old_board_id between the read - # above and this write, so the decision taken about that board no longer - # applies — and reporting a removal that did not happen invalidates the - # wrong boards while the client counts the name as done. Classified by - # where the image is now: - current_board_id = ApiDependencies.invoker.services.board_image_records.get_board_for_image( - image_name - ) - if current_board_id is not None: - # Moved to another board: the caller's ask — off every board — is not - # satisfied, and a retry will re-read and re-authorize against the - # board it actually sits on now. - failed_images.add(image_name) - continue - # Probed directly rather than through _image_record_exists: that helper - # answers True on a storage error, which is the conservative bias where - # True means "report as failed" (the add loop) — here True means "report - # as removed", and a transient storage error must not manufacture a - # success whose tag-driven getImageDTO refetch then 404s. A storage error - # propagates to the arm below instead: a name whose state cannot be - # decided is reported as failed, never as done. - try: - ApiDependencies.invoker.services.image_records.get(image_name) - except ImageRecordNotFoundException: - # Deleted concurrently — a skip, exactly as the gone-block above - # treats a name that vanished before the loop reached it. Reporting - # it removed would drive a getImageDTO refetch straight into a 404. - continue - # Concurrently uncategorized by someone else: the postcondition the - # caller asked for holds, so report it as removed — the invalidation is - # what lets this client's view of the old board catch up. Safe in - # removed_images, unlike a deleted name: the DTO exists, so the - # tag-driven refetches succeed. + outcome = _remove_from_board_and_classify(image_name, old_board_id) + if outcome is _ScopedRemoveOutcome.REMOVED: removed_images.add(image_name) affected_boards.add("none") affected_boards.add(old_board_id) - continue - removed_images.add(image_name) - affected_boards.add("none") - affected_boards.add(old_board_id) + elif outcome is _ScopedRemoveOutcome.MOVED: + failed_images.add(image_name) + # GONE: a skip, exactly as the gone-block above treats a name that vanished + # before the loop reached it. except Exception: # A genuine storage failure, not an auth/404 skip — see add_images_to_board. # The zero-row classification's own reads land here too: a name whose state diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts index edfe32e2007..47e607b9d36 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts @@ -255,7 +255,7 @@ describe('buildChunkedImageBatchQueryFn', () => { type Arg = { image_names: string[]; board_id?: string }; type Result = { added_images: string[]; failed_images: string[]; affected_boards: string[] }; type Request = { url: string; method: string; body: Arg }; - type Response = { data: Result } | { error: { status: number; data: string } }; + type Response = { data: Result } | { error: { status: number | string; data: string } }; const getTags = () => ['ImageCollectionCounts' as const]; @@ -267,7 +267,8 @@ describe('buildChunkedImageBatchQueryFn', () => { const dispatch = vi.fn(); const queryFn = buildChunkedImageBatchQueryFn( () => ({ url: '/api/v1/board_images/batch', method: 'POST' }), - getTags + getTags, + (image_names) => ({ added_images: image_names, failed_images: [], affected_boards: [] }) ); /* eslint-disable @typescript-eslint/no-explicit-any */ return { dispatch, result: queryFn(arg, { dispatch } as any, undefined, baseQuery as any) }; @@ -351,6 +352,87 @@ describe('buildChunkedImageBatchQueryFn', () => { expect(toast).not.toHaveBeenCalled(); }); + it('invalidates the failing chunk as if it had landed when the error cannot prove otherwise', async () => { + // A transport error can strike after the server committed the chunk -- the response is + // what was lost, not necessarily the request. The names are still reported failed, which + // is the honest reading and safe to retry, but the caches the chunk may have touched are + // invalidated as if it had landed, so the refetch shows the truth either way. + let call = 0; + const baseQuery = vi.fn((_args: Request): Promise => { + call += 1; + if (call === 2) { + return Promise.resolve({ error: { status: 'FETCH_ERROR', data: 'network' } }); + } + return Promise.resolve({ + data: { added_images: [`chunk-${call}.png`], failed_images: [], affected_boards: ['board-1'] }, + }); + }); + const dispatch = vi.fn(); + // Result-sensitive tags, so the two invalidations are distinguishable in the assertions. + const resultTags = (result: Result) => [{ type: 'Image' as const, id: result.added_images[0] ?? 'none' }]; + const queryFn = buildChunkedImageBatchQueryFn( + () => ({ url: '/api/v1/board_images/batch', method: 'POST' }), + resultTags, + (image_names) => ({ added_images: image_names, failed_images: [], affected_boards: [] }) + ); + /* eslint-disable @typescript-eslint/no-explicit-any */ + const result = await queryFn({ image_names: names(1500) }, { dispatch } as any, undefined, baseQuery as any); + /* eslint-enable @typescript-eslint/no-explicit-any */ + + // The assumed-committed invalidation is keyed by the failing chunk's own names... + expect(dispatch).toHaveBeenCalledWith(api.util.invalidateTags([{ type: 'Image', id: 'image-1000.png' }])); + // ...and the merged invalidation for the chunks that did land still happens. + expect(dispatch).toHaveBeenCalledWith(api.util.invalidateTags([{ type: 'Image', id: 'chunk-1.png' }])); + expect(result).toEqual({ + data: { added_images: ['chunk-1.png'], failed_images: names(1500).slice(1000), affected_boards: ['board-1'] }, + }); + }); + + it('does not second-guess a chunk the server itself refused', async () => { + // A 4xx is the server saying it did nothing, so the failing chunk's caches hold no lie to + // reconcile -- only the merged invalidation for the landed chunks fires. + let call = 0; + const baseQuery = vi.fn((_args: Request): Promise => { + call += 1; + if (call === 2) { + return Promise.resolve({ error: { status: 403, data: 'nope' } }); + } + return Promise.resolve({ + data: { added_images: [`chunk-${call}.png`], failed_images: [], affected_boards: ['board-1'] }, + }); + }); + const assumeCommitted = vi.fn((image_names: string[]): Result => { + return { added_images: image_names, failed_images: [], affected_boards: [] }; + }); + const dispatch = vi.fn(); + const queryFn = buildChunkedImageBatchQueryFn( + () => ({ url: '/api/v1/board_images/batch', method: 'POST' }), + getTags, + assumeCommitted + ); + /* eslint-disable @typescript-eslint/no-explicit-any */ + await queryFn({ image_names: names(1500) }, { dispatch } as any, undefined, baseQuery as any); + /* eslint-enable @typescript-eslint/no-explicit-any */ + + expect(assumeCommitted).not.toHaveBeenCalled(); + expect(dispatch).toHaveBeenCalledTimes(1); + }); + + it('reports a first-chunk transport failure as an error, but still reconciles its caches', async () => { + // Nothing is known to have landed, so the run is reported as the failure it probably was + // -- but "probably" is the point: the chunk may have committed, so its tags are + // invalidated before the error goes back. + const baseQuery = vi.fn( + (_args: Request): Promise => Promise.resolve({ error: { status: 'TIMEOUT_ERROR', data: 'slow' } }) + ); + const { dispatch, result } = run(baseQuery, { image_names: names(1500) }); + + expect(await result).toEqual({ error: { status: 'TIMEOUT_ERROR', data: 'slow' } }); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith(api.util.invalidateTags(getTags())); + expect(toast).not.toHaveBeenCalled(); + }); + it('stops when the session changes mid-run, instead of applying the rest as the new user', async () => { // Every request picks up whatever token localStorage holds when it is sent, so a loop that // outlives its own session finishes as whoever logged in next -- on a public board those @@ -666,6 +748,34 @@ describe('bulkDownloadQueryFn', () => { expect(i18n.t).not.toHaveBeenCalledWith('toast.imagesFailedToDownload', expect.anything()); }); + it('warns and withholds the payload when the session expires during the final chunk', async () => { + // Mid-loop expiry is caught by the next chunk's pre-request check, but the final chunk has + // no next iteration -- and fetchChunk's post-response check deliberately lets a mere + // expiry through. Without a post-loop check the run returns `first`, and matchFulfilled + // raises the `duration: null` "preparing" toast into a session whose socket will never + // deliver the dismissal -- while the zips, all scheduled, are lost silently. + login('user-a'); + let call = 0; + const baseQuery = vi.fn((_args: Request): Promise => { + call += 1; + if (call === 3) { + localStorage.removeItem('auth_token'); + } + return Promise.resolve({ data: { bulk_download_item_name: `item-${call}.zip` } }); + }); + + const result = await run(baseQuery, { image_names: names(2500) }); + + expect(baseQuery).toHaveBeenCalledTimes(3); + expect(result).toEqual({ data: undefined }); + expect(toast).toHaveBeenCalledTimes(1); + expect(vi.mocked(toast).mock.calls[0]?.[0]).toMatchObject({ + id: 'DOWNLOADS_INTERRUPTED', + title: 'toast.downloadsInterrupted', + status: 'warning', + }); + }); + it("treats the chunk's own expired-session 401 as an interruption, not a partial failure", async () => { // The everyday expiry vector: the 401 arrives on the download's own chunk, and // dynamicBaseQuery has already cleared the token by the time it returns. The mutating diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index 81cadd75e1c..203ac110d68 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -109,6 +109,20 @@ const isSessionMismatchError = (error: FetchBaseQueryError | undefined): boolean isAuthChangedError(error) || (error?.status === SESSION_ENDED_ERROR.status && error.error === SESSION_ENDED_ERROR.error); +/** + * Errors that leave a chunk's outcome unknown. A transport failure or timeout can strike after + * the request reached the server -- fetch loses the response, not necessarily the request -- a + * parsing error means a response arrived for work that was already done, and a 5xx says the + * route died somewhere in a loop whose per-name writes had each already committed. In all of + * these the server may have applied the chunk with only the report lost. A 4xx is the server + * itself saying it refused, which is the one shape that proves the chunk did nothing. + */ +const isIndeterminateError = (error: FetchBaseQueryError): boolean => + error.status === 'FETCH_ERROR' || + error.status === 'TIMEOUT_ERROR' || + error.status === 'PARSING_ERROR' || + (typeof error.status === 'number' && error.status >= 500); + /** * Which of the two a failed session check means. The distinction is the whole ballgame: expiry * must degrade into an ordinary failure so committed work is still reported, while a takeover @@ -226,7 +240,8 @@ export const mergeImageBatchResults = (results export const buildChunkedImageBatchQueryFn = ( request: (body: TArg) => { url: string; method: string }, - getTags: (result: TResult) => InvalidateTagsArg + getTags: (result: TResult) => InvalidateTagsArg, + assumeCommitted: (image_names: string[], arg: TArg) => TResult ) => async ( arg: TArg, @@ -245,6 +260,20 @@ export const buildChunkedImageBatchQueryFn = // invalidate the new session or return partial data that its UI can apply. return { error: response.error }; } + if (isIndeterminateError(response.error)) { + // A transport-shaped failure proves only that the report was lost: the server may + // have committed this chunk with nothing coming back to say so. The names still go + // to failed_images below -- the honest reading, and retrying a name the server + // already satisfied is safe on every batch route -- but the caches this chunk may + // have touched must not keep serving the pre-request state until the user notices, + // so they are invalidated as if the chunk had landed: the refetch shows the truth + // either way. `assumeCommitted` builds the result this chunk would have returned on + // success, so the tags come from the same `getTags` the endpoint publishes and the + // two cannot drift. A lost chunk's affected boards are unknowable, so callers + // assume none: the board-affecting tag helper still returns the global gallery-list + // tags, which is what makes the visible views refetch. + dispatch(api.util.invalidateTags(getTags(assumeCommitted(image_names, arg)))); + } if (results.length === 0) { // Nothing was applied, so this is an ordinary failed request — report it as one. return { error: response.error }; @@ -501,6 +530,24 @@ export const bulkDownloadQueryFn = async ( scheduled = true; first ??= response.data as components['schemas']['ImagesDownloaded']; } + // The final chunk has no next iteration to catch an expiry for it. `fetchChunk`'s own + // post-response check deliberately passes a mere expiry through -- the mutating loops need + // that -- so a token cleared during the last chunk's await (a 401 on any concurrent request + // does it) would otherwise sail into the return below, and `matchFulfilled` would raise the + // `duration: null` "preparing" toast into a session whose socket will never deliver the + // dismissal -- or the zips. Same triage as the in-loop expiry arm: say what was lost, and + // hand `matchFulfilled` nothing to toast on. Synchronous from here to the return, so there + // is no later window this check misses. + if (!isSameAuthContext(authContext)) { + if (localStorage.getItem('auth_token') === null && scheduled) { + toast({ + id: 'DOWNLOADS_INTERRUPTED', + title: i18n.t('toast.downloadsInterrupted'), + status: 'warning', + }); + } + return { data: undefined as unknown as components['schemas']['ImagesDownloaded'] }; + } return { data: first as components['schemas']['ImagesDownloaded'] }; }; @@ -642,7 +689,8 @@ export const imagesApi = api.injectEndpoints({ >({ queryFn: buildChunkedImageBatchQueryFn( () => ({ url: buildImagesUrl('delete'), method: 'POST' }), - getDeleteImagesTags + getDeleteImagesTags, + (image_names) => ({ deleted_images: image_names, failed_images: [], affected_boards: [] }) ), onQueryStarted: reportImageBatchOutcome, invalidatesTags: (result) => (result ? getDeleteImagesTags(result) : []), @@ -697,7 +745,8 @@ export const imagesApi = api.injectEndpoints({ >({ queryFn: buildChunkedImageBatchQueryFn( () => ({ url: buildImagesUrl('star'), method: 'POST' }), - getStarImagesTags + getStarImagesTags, + (image_names) => ({ starred_images: image_names, failed_images: [], affected_boards: [] }) ), onQueryStarted: reportImageBatchOutcome, invalidatesTags: (result) => (result ? getStarImagesTags(result) : []), @@ -711,7 +760,8 @@ export const imagesApi = api.injectEndpoints({ >({ queryFn: buildChunkedImageBatchQueryFn( () => ({ url: buildImagesUrl('unstar'), method: 'POST' }), - getUnstarImagesTags + getUnstarImagesTags, + (image_names) => ({ unstarred_images: image_names, failed_images: [], affected_boards: [] }) ), onQueryStarted: reportImageBatchOutcome, invalidatesTags: (result) => (result ? getUnstarImagesTags(result) : []), @@ -866,6 +916,9 @@ export const imagesApi = api.injectEndpoints({ } return [ ...getTagsToInvalidateForImageMutation(result.removed_images), + // A name the zero-row classification reported as failed sits on a board this client + // did not expect; refetching its DTO is what shows where it actually went. + ...getTagsToInvalidateForImageMutation(result.failed_images), ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards), ]; }, @@ -876,7 +929,8 @@ export const imagesApi = api.injectEndpoints({ >({ queryFn: buildChunkedImageBatchQueryFn( () => ({ url: buildBoardImagesUrl('batch'), method: 'POST' }), - getAddImagesToBoardTags + getAddImagesToBoardTags, + (image_names, arg) => ({ added_images: image_names, failed_images: [], affected_boards: [arg.board_id] }) ), onQueryStarted: reportImageBatchOutcome, invalidatesTags: (result) => (result ? getAddImagesToBoardTags(result) : []), @@ -887,7 +941,8 @@ export const imagesApi = api.injectEndpoints({ >({ queryFn: buildChunkedImageBatchQueryFn( () => ({ url: buildBoardImagesUrl('batch/delete'), method: 'POST' }), - getRemoveImagesFromBoardTags + getRemoveImagesFromBoardTags, + (image_names) => ({ removed_images: image_names, failed_images: [], affected_boards: [] }) ), onQueryStarted: reportImageBatchOutcome, invalidatesTags: (result) => (result ? getRemoveImagesFromBoardTags(result) : []), diff --git a/tests/app/routers/test_board_images_batch_races.py b/tests/app/routers/test_board_images_batch_races.py index 6a3ce1b77e5..90c05dc0063 100644 --- a/tests/app/routers/test_board_images_batch_races.py +++ b/tests/app/routers/test_board_images_batch_races.py @@ -223,3 +223,92 @@ def _cm(): assert storage.remove_image_from_board("raced.png", "board-p") == 0 _Cursor.rowcount = 1 assert storage.remove_image_from_board("ok.png", "board-p") == 1 + + +@pytest.mark.parametrize( + ("now_on_board", "record_exists", "expect_removed", "expect_failed", "expect_boards"), + [ + # Same three classifications as the batch loop above -- the single-image route runs + # the identical read-then-scoped-DELETE sequence, so it loses the identical race. It + # used to ignore the row count entirely and answer removed_images=[name] for all three + # of these, a false success the client had no way to see through. + ("board-q", True, [], ["raced.png"], []), + (None, True, ["raced.png"], [], ["none", "board-p"]), + (None, False, [], [], []), + ], +) +def test_single_remove_classifies_a_zero_row_scoped_delete_like_the_batch_route( + monkeypatch: pytest.MonkeyPatch, + mock_invoker: Invoker, + client: TestClient, + now_on_board: str | None, + record_exists: bool, + expect_removed: list[str], + expect_failed: list[str], + expect_boards: list[str], +) -> None: + _install(monkeypatch, mock_invoker) + dto = MagicMock() + dto.board_id = "board-p" + monkeypatch.setattr(mock_invoker.services.images, "get_dto", MagicMock(return_value=dto)) + monkeypatch.setattr(mock_invoker.services.board_images, "remove_image_from_board", MagicMock(return_value=0)) + monkeypatch.setattr( + mock_invoker.services.board_image_records, "get_board_for_image", MagicMock(return_value=now_on_board) + ) + if record_exists: + monkeypatch.setattr(mock_invoker.services.image_records, "get", MagicMock(return_value=MagicMock())) + else: + from invokeai.app.services.image_records.image_records_common import ImageRecordNotFoundException + + monkeypatch.setattr( + mock_invoker.services.image_records, "get", MagicMock(side_effect=ImageRecordNotFoundException) + ) + + response = client.request("DELETE", "/api/v1/board_images/", json={"image_name": "raced.png"}) + + assert response.status_code == 201 + body = response.json() + assert body["removed_images"] == expect_removed + assert body["failed_images"] == expect_failed + assert set(body["affected_boards"]) == set(expect_boards) + + +def test_single_remove_of_an_uncategorized_image_reports_removed_without_a_write( + monkeypatch: pytest.MonkeyPatch, mock_invoker: Invoker, client: TestClient +) -> None: + """No board_images row ever carries board_id="none", so the scoped DELETE could not match: + issuing it and then classifying the guaranteed zero-row miss would spend reads confirming + what the DTO already said. The postcondition holds, so it is removed, with no write at all.""" + _install(monkeypatch, mock_invoker) + dto = MagicMock() + dto.board_id = None + monkeypatch.setattr(mock_invoker.services.images, "get_dto", MagicMock(return_value=dto)) + remove = MagicMock(return_value=1) + monkeypatch.setattr(mock_invoker.services.board_images, "remove_image_from_board", remove) + + response = client.request("DELETE", "/api/v1/board_images/", json={"image_name": "loose.png"}) + + assert response.status_code == 201 + body = response.json() + assert body["removed_images"] == ["loose.png"] + assert body["failed_images"] == [] + assert body["affected_boards"] == ["none"] + remove.assert_not_called() + + +def test_single_remove_still_reports_a_nonzero_scoped_delete_as_removed( + monkeypatch: pytest.MonkeyPatch, mock_invoker: Invoker, client: TestClient +) -> None: + _install(monkeypatch, mock_invoker) + dto = MagicMock() + dto.board_id = "board-p" + monkeypatch.setattr(mock_invoker.services.images, "get_dto", MagicMock(return_value=dto)) + monkeypatch.setattr(mock_invoker.services.board_images, "remove_image_from_board", MagicMock(return_value=1)) + + response = client.request("DELETE", "/api/v1/board_images/", json={"image_name": "ok.png"}) + + assert response.status_code == 201 + body = response.json() + assert body["removed_images"] == ["ok.png"] + assert body["failed_images"] == [] + assert set(body["affected_boards"]) == {"none", "board-p"} diff --git a/tests/app/routers/test_multiuser_authorization.py b/tests/app/routers/test_multiuser_authorization.py index eeabd674b08..2b1d4e48893 100644 --- a/tests/app/routers/test_multiuser_authorization.py +++ b/tests/app/routers/test_multiuser_authorization.py @@ -186,6 +186,11 @@ def enable_multiuser(monkeypatch: Any, mock_invoker: Invoker): mock_board_images = MagicMock() mock_board_images.get_all_board_image_names_for_board.return_value = [] + # The real facade returns the scoped DELETE's row count, and the routes classify a zero-row + # miss as not-removed. A bare MagicMock return only passed the old `== 0` check by accident; + # under `> 0` it is a TypeError. One row deleted is the honest default for a mock whose + # remove is expected to succeed; tests that stage the miss override this per-call. + mock_board_images.remove_image_from_board.return_value = 1 mock_invoker.services.board_images = mock_board_images mock_workflow_thumbnails = MagicMock() @@ -568,8 +573,11 @@ def test_batch_remove_stops_when_board_write_access_is_revoked_mid_batch( _save_image(mock_invoker, name, user1.user_id) mock_invoker.services.board_image_records.add_image_to_board(board_id, name) - def _revoke_after_first_removal(image_name: str, board_id: str) -> None: + def _revoke_after_first_removal(image_name: str, board_id: str) -> int: mock_invoker.services.board_records.update(board_id, BoardChanges(board_visibility=BoardVisibility.Private)) + # One row removed: a side_effect's return value overrides the mock's return_value, + # and the route classifies anything else as a miss. + return 1 mock_invoker.services.board_images.remove_image_from_board.reset_mock() mock_invoker.services.board_images.remove_image_from_board.side_effect = _revoke_after_first_removal From a18445b963a210917eebfa46d8178a3ef3a790a0 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sun, 23 Aug 2026 12:42:28 -0400 Subject: [PATCH 25/34] fix(ui): close the self-review's two residual gaps in the lost-chunk reconciliation - The failed_images DTO invalidation landed only on the single-image removeImageFromBoard endpoint, which nothing in the app calls; the live batch path classifies the same zero-row MOVED race and left the stale DTO in place. getRemoveImagesFromBoardTags now invalidates failed names too. - assumeCommitted's empty affected_boards reached the global gallery-list tags but none of the board-keyed ones, so a lost single-chunk delete left every board count stale until something unrelated bumped it. The indeterminate-path dispatch now appends the board-keyed tag types type-wide, since the lost chunk's boards are unknowable. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UE38kzGWoj4w6dYiPqa3CY --- .../src/services/api/endpoints/images.test.ts | 17 +++++++++++++--- .../web/src/services/api/endpoints/images.ts | 20 ++++++++++++++++--- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts index 47e607b9d36..efa18bb3704 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts @@ -379,8 +379,17 @@ describe('buildChunkedImageBatchQueryFn', () => { const result = await queryFn({ image_names: names(1500) }, { dispatch } as any, undefined, baseQuery as any); /* eslint-enable @typescript-eslint/no-explicit-any */ - // The assumed-committed invalidation is keyed by the failing chunk's own names... - expect(dispatch).toHaveBeenCalledWith(api.util.invalidateTags([{ type: 'Image', id: 'image-1000.png' }])); + // The assumed-committed invalidation is keyed by the failing chunk's own names, with the + // board-keyed tag types appended type-wide, since the lost chunk's boards are unknowable. + expect(dispatch).toHaveBeenCalledWith( + api.util.invalidateTags([ + { type: 'Image', id: 'image-1000.png' }, + 'ImageList', + 'Board', + 'BoardImagesTotal', + 'BoardVideosTotal', + ]) + ); // ...and the merged invalidation for the chunks that did land still happens. expect(dispatch).toHaveBeenCalledWith(api.util.invalidateTags([{ type: 'Image', id: 'chunk-1.png' }])); expect(result).toEqual({ @@ -429,7 +438,9 @@ describe('buildChunkedImageBatchQueryFn', () => { expect(await result).toEqual({ error: { status: 'TIMEOUT_ERROR', data: 'slow' } }); expect(dispatch).toHaveBeenCalledTimes(1); - expect(dispatch).toHaveBeenCalledWith(api.util.invalidateTags(getTags())); + expect(dispatch).toHaveBeenCalledWith( + api.util.invalidateTags([...getTags(), 'ImageList', 'Board', 'BoardImagesTotal', 'BoardVideosTotal']) + ); expect(toast).not.toHaveBeenCalled(); }); diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index 203ac110d68..47c3db13a4a 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -270,9 +270,19 @@ export const buildChunkedImageBatchQueryFn = // either way. `assumeCommitted` builds the result this chunk would have returned on // success, so the tags come from the same `getTags` the endpoint publishes and the // two cannot drift. A lost chunk's affected boards are unknowable, so callers - // assume none: the board-affecting tag helper still returns the global gallery-list - // tags, which is what makes the visible views refetch. - dispatch(api.util.invalidateTags(getTags(assumeCommitted(image_names, arg)))); + // assume none — which reaches the global gallery-list tags but none of the + // board-keyed ones (`Board`, `BoardImagesTotal`, ..., all keyed by id). Those are + // appended type-wide instead: every board's sidebar count refetching once beats a + // count that stays wrong until something unrelated bumps it. + dispatch( + api.util.invalidateTags([ + ...getTags(assumeCommitted(image_names, arg)), + 'ImageList', + 'Board', + 'BoardImagesTotal', + 'BoardVideosTotal', + ]) + ); } if (results.length === 0) { // Nothing was applied, so this is an ordinary failed request — report it as one. @@ -328,6 +338,10 @@ const getRemoveImagesFromBoardTags = ( result: components['schemas']['RemoveImagesFromBoardResult'] ): InvalidateTagsArg => [ ...getTagsToInvalidateForImageMutation(result.removed_images), + // A name the zero-row classification reports as failed sits on a board this client did not + // expect — refetching its DTO is what shows where it actually went. Names folded in + // client-side for unreached chunks ride along; their refetch merely confirms the cache. + ...getTagsToInvalidateForImageMutation(result.failed_images), ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards), ]; From 8a508f738b70854f6f2fa29b412aa80e71b891a8 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sun, 23 Aug 2026 17:55:57 -0400 Subject: [PATCH 26/34] fix(api,ui): abort errored chunks under takeover, and stop laundering storage errors into auth skips Two round-10 review findings: - fetchChunk triaged only *successful* responses for takeover, on the theory that an error carries nothing the next session could consume. Since the indeterminate-error reconciliation, the error path consumes plenty: it dispatches as-if-committed invalidations and returns partial aggregates the UI applies. An error returning into a taken-over session is now rewritten to the auth-changed hard abort, so the loops consume nothing. Mere expiry still passes through untriaged - the everyday 401 must keep reaching the partial path, where committed work is reported and pruned. - assert_image_owner wrapped the board-ownership fallback in a bare except-pass, so a database error during the board lookup became a 403 - and the batch star/unstar loops treat a 403 as a silent auth skip: not applied, not reported, nothing toasted. The lookup now reads the board record (owner and visibility are all the decision needs), catches only BoardRecordNotFoundException, and lets storage errors propagate into each loop's failed_images arm. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UE38kzGWoj4w6dYiPqa3CY --- invokeai/app/api/routers/_access.py | 22 +++++++-- .../src/services/api/endpoints/images.test.ts | 32 +++++++++++++ .../web/src/services/api/endpoints/images.ts | 15 ++++-- .../routers/test_multiuser_authorization.py | 47 +++++++++++++++++++ 4 files changed, 108 insertions(+), 8 deletions(-) diff --git a/invokeai/app/api/routers/_access.py b/invokeai/app/api/routers/_access.py index fae3971a144..4ffa08fbe44 100644 --- a/invokeai/app/api/routers/_access.py +++ b/invokeai/app/api/routers/_access.py @@ -8,7 +8,10 @@ from invokeai.app.api.auth_dependencies import CurrentUserOrDefault from invokeai.app.api.dependencies import ApiDependencies -from invokeai.app.services.board_records.board_records_common import BoardVisibility +from invokeai.app.services.board_records.board_records_common import ( + BoardRecordNotFoundException, + BoardVisibility, +) def assert_image_owner(image_name: str, current_user: CurrentUserOrDefault) -> None: @@ -28,14 +31,25 @@ def assert_image_owner(image_name: str, current_user: CurrentUserOrDefault) -> N board_id = ApiDependencies.invoker.services.board_image_records.get_board_for_image(image_name) if board_id is not None: + # The board *record*, not its DTO: the decision needs only the owner and the + # visibility, and the DTO would drag in cover-image resolution plus three COUNT + # aggregates — five extra queries and five extra ways to fail per name. + # + # Only a board positively known to be gone falls through to the 403. A storage error + # propagates instead of being caught here: `board_records.get` deliberately does not + # translate sqlite errors into not-found, and a caller that cannot decide ownership + # must not report the name as an ordinary permission denial — the batch loops treat a + # 403 as a silent auth skip, which turned a locked database into images dropped from + # the response with no failure reported at all. try: - board = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id) + board = ApiDependencies.invoker.services.board_records.get(board_id) + except BoardRecordNotFoundException: + pass + else: if board.user_id == current_user.user_id: return if board.board_visibility == BoardVisibility.Public: return - except Exception: - pass raise HTTPException(status_code=403, detail="Not authorized to modify this image") diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts index efa18bb3704..2cf722b120c 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts @@ -352,6 +352,38 @@ describe('buildChunkedImageBatchQueryFn', () => { expect(toast).not.toHaveBeenCalled(); }); + it('aborts an errored chunk that returns into a taken-over session, consuming nothing', async () => { + // The error path consumes state too — since the indeterminate-error reconciliation it + // dispatches as-if-committed invalidations, and the partial path returns an aggregate the + // UI applies. A takeover discovered when the chunk comes back must therefore hard-abort + // BEFORE that handling runs, exactly as it does for a successful payload. Staged with an + // indeterminate 500, the worst case: without the triage it would both invalidate and + // return user A's partial result into user B's session. + login('user-a'); + let call = 0; + const baseQuery = vi.fn((_args: Request): Promise => { + call += 1; + if (call === 2) { + switchUser('user-b'); + return Promise.resolve({ error: { status: 500, data: 'boom' } }); + } + return Promise.resolve({ + data: { added_images: [`chunk-${call}.png`], failed_images: [], affected_boards: ['board-1'] }, + }); + }); + + const { dispatch, result } = run(baseQuery, { image_names: names(1500) }); + + expect(await result).toEqual({ + error: { + status: 'CUSTOM_ERROR', + error: 'Aborted: the authenticated session changed while the operation was running', + }, + }); + expect(dispatch).not.toHaveBeenCalled(); + expect(toast).not.toHaveBeenCalled(); + }); + it('invalidates the failing chunk as if it had landed when the error cannot prove otherwise', async () => { // A transport error can strike after the server committed the chunk -- the response is // what was lost, not necessarily the request. The names are still reported failed, which diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index 47c3db13a4a..1d4f2fc4c5f 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -168,11 +168,18 @@ const fetchChunk = async ( return { error: sessionMismatchError() }; } const response = await baseQuery(args); - // An error passes through untriaged: it carries nothing the next session could consume, and - // rewriting it loses what it was. This chunk's own expired-session 401 is the everyday case — - // `dynamicBaseQuery` dispatches `sessionExpiredLogout` before returning it, so the token is - // already gone by this line, and a rewrite would turn the ordinary failure into an abort. + // An error is triaged like a success, but only for takeover. Mere expiry passes through + // untriaged: this chunk's own expired-session 401 is the everyday case — `dynamicBaseQuery` + // dispatches `sessionExpiredLogout` before returning it, so the token is already gone by + // this line, and a rewrite would turn the ordinary failure into an abort that discards the + // committed chunks' report. A takeover, though, must not reach the loops' error handling at + // all: that handling *consumes* — it dispatches as-if-committed invalidations and returns + // partial aggregates the UI applies — and everything it would consume belongs to the + // session that started the run, not to whoever owns the tab now. if (response.error) { + if (localStorage.getItem('auth_token') !== null && !isSameAuthContext(authContext)) { + return { error: AUTH_CHANGED_ERROR }; + } return response; } // A *successful* payload is consumed unless another user has taken over. Mere expiry keeps diff --git a/tests/app/routers/test_multiuser_authorization.py b/tests/app/routers/test_multiuser_authorization.py index 2b1d4e48893..c4c8512faaf 100644 --- a/tests/app/routers/test_multiuser_authorization.py +++ b/tests/app/routers/test_multiuser_authorization.py @@ -1248,6 +1248,53 @@ def test_non_owner_cannot_star_image( assert body["failed_images"] == [] assert mock_invoker.services.image_records.get("user1-star-blocked").starred is False + @pytest.mark.parametrize("route", ["star", "unstar"]) + def test_star_reports_a_name_whose_board_lookup_hit_a_storage_error( + self, + client: TestClient, + mock_invoker: Invoker, + monkeypatch: Any, + user1_token: str, + user2_token: str, + route: str, + ): + """A database error during the board-ownership fallback must land in failed_images. + + `assert_image_owner` used to catch every exception from the board lookup and fall + through to the 403, and the batch loops treat a 403 as a silent auth skip -- so a + locked database made the star quietly vanish from the response: not applied, not + reported, and nothing for the client to toast. Only a board positively known to be + gone may still answer 403; a lookup that cannot be decided must propagate into the + loop's storage-failure arm. + """ + import sqlite3 + + user1 = mock_invoker.services.users.get_by_email("user1@test.com") + assert user1 is not None + board_id = _create_board(client, user1_token, f"User1 Public {route} Board") + _set_board_visibility(client, user1_token, board_id, "public") + name = f"user1-{route}-undecidable" + _save_image(mock_invoker, name, user1.user_id) + mock_invoker.services.board_image_records.add_image_to_board(board_id, name) + + # Patched only after the setup above used the real store. user2 is neither admin nor + # direct owner, so the decision reaches the board lookup and cannot complete. + monkeypatch.setattr( + mock_invoker.services.board_records, + "get", + MagicMock(side_effect=sqlite3.OperationalError("database is locked")), + ) + + r = client.post( + f"/api/v1/images/{route}", + json={"image_names": [name]}, + headers=_auth(user2_token), + ) + assert r.status_code == status.HTTP_200_OK + body = r.json() + assert body[f"{route}red_images"] == [] + assert body["failed_images"] == [name] + def test_non_owner_cannot_batch_delete_image( self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str ): From 4c56cd0fca7cb95d3012d3b40ed89cd473d6216b Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sun, 23 Aug 2026 18:07:29 -0400 Subject: [PATCH 27/34] test(api): pin the narrowed catch from the not-found side too An implementation that let BoardRecordNotFoundException propagate alongside the storage errors would toast a failure for a name whose only problem is that its board vanished mid-request. The gone-board arm stays a silent auth skip. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UE38kzGWoj4w6dYiPqa3CY --- .../routers/test_multiuser_authorization.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/app/routers/test_multiuser_authorization.py b/tests/app/routers/test_multiuser_authorization.py index c4c8512faaf..2a64275b908 100644 --- a/tests/app/routers/test_multiuser_authorization.py +++ b/tests/app/routers/test_multiuser_authorization.py @@ -1295,6 +1295,42 @@ def test_star_reports_a_name_whose_board_lookup_hit_a_storage_error( assert body[f"{route}red_images"] == [] assert body["failed_images"] == [name] + def test_star_still_skips_a_name_whose_board_is_positively_gone( + self, client: TestClient, mock_invoker: Invoker, monkeypatch: Any, user1_token: str, user2_token: str + ): + """The narrowed catch must stay exactly that narrow, in both directions. + + A board positively known to be gone (a dangling board_image row) is the one lookup + outcome that may still answer the ordinary 403 -- an auth skip, absent from both + lists. An implementation that let BoardRecordNotFoundException propagate alongside + the storage errors would report it in failed_images and toast a failure for a name + whose only problem is that its board vanished mid-request. + """ + from invokeai.app.services.board_records.board_records_common import BoardRecordNotFoundException + + user1 = mock_invoker.services.users.get_by_email("user1@test.com") + assert user1 is not None + board_id = _create_board(client, user1_token, "User1 Vanishing Board") + _set_board_visibility(client, user1_token, board_id, "public") + _save_image(mock_invoker, "user1-board-gone", user1.user_id) + mock_invoker.services.board_image_records.add_image_to_board(board_id, "user1-board-gone") + + monkeypatch.setattr( + mock_invoker.services.board_records, + "get", + MagicMock(side_effect=BoardRecordNotFoundException), + ) + + r = client.post( + "/api/v1/images/star", + json={"image_names": ["user1-board-gone"]}, + headers=_auth(user2_token), + ) + assert r.status_code == status.HTTP_200_OK + body = r.json() + assert body["starred_images"] == [] + assert body["failed_images"] == [] + def test_non_owner_cannot_batch_delete_image( self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str ): From 4ae99b31dd29c45a82f369e76258ef530c3962ec Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sun, 23 Aug 2026 21:55:40 -0400 Subject: [PATCH 28/34] fix(ui): stop a stale 401 from ending the session that replaced it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dynamicBaseQuery` ended the session on any 401 that carried a token, using the token captured when the request went out. A request that is still in flight when someone else takes over the tab — a login here, or one in another tab, since localStorage is shared — then logs out the user who never issued it. The 401 is only evidence about the credential that was sent, so the session ends only while that credential is still the live one. Byte equality, deliberately the opposite of `isSameAuthContext`: a sliding-window refresh must not qualify either, because a 401 for the token it replaced says nothing about the replacement. Nothing is lost by waiting — the next request carries the live token and its 401 ends the session here. Also invalidate `failed_images` on star/unstar. `ImageService.update` writes the record and then reads the DTO back; a failure in that read reports the name as failed with the row already starred, and invalidating only the successes leaves the client showing the pre-star value until a full reload. The remove-from-board helper already does this for the same reason. --- .../features/auth/store/authTokenRefresh.ts | 22 +++ .../src/services/api/endpoints/images.test.ts | 143 +++++++++++++++++- .../web/src/services/api/endpoints/images.ts | 11 ++ .../frontend/web/src/services/api/index.ts | 13 +- 4 files changed, 183 insertions(+), 6 deletions(-) diff --git a/invokeai/frontend/web/src/features/auth/store/authTokenRefresh.ts b/invokeai/frontend/web/src/features/auth/store/authTokenRefresh.ts index 8b1a7a84895..45c1d69784e 100644 --- a/invokeai/frontend/web/src/features/auth/store/authTokenRefresh.ts +++ b/invokeai/frontend/web/src/features/auth/store/authTokenRefresh.ts @@ -45,6 +45,28 @@ export const beginAuthTransition = () => { export const shouldAcceptRefreshedToken = (requestToken: string, requestGeneration: number) => getAuthGeneration() === requestGeneration && localStorage.getItem('auth_token') === requestToken; +/** + * True when a 401 for a request that carried `requestToken` should end the live session. + * + * A 401 is evidence about the credential that was *sent*, and about nothing else. By the time + * one lands, the tab may already belong to someone else: `setCredentials` (a login here) and + * `externalTokenAdopted` (a login in another tab — localStorage is shared) both swap the token + * synchronously while earlier requests are still in flight. Ending the session on such a 401 + * destroys the session that just replaced it, and the user who never issued the request is the + * one logged out. So the token that was sent must still be the live one. + * + * Byte equality, deliberately — and note this is the opposite of what `isSameAuthContext` wants. + * A sliding-window refresh must NOT pass here: it mints a new token for the same login, and a + * 401 for the token it replaced says nothing about whether the replacement is still good. That + * costs nothing, because the session ending is not a one-shot event to be caught — if it really + * has ended, the next request carries the live token and its 401 ends it here. + * + * A null `requestToken` never qualifies: unauthenticated requests (client_state probes during + * page load, the setup-status query) 401 routinely and must not log anyone out. + */ +export const shouldEndSessionForUnauthorized = (requestToken: string | null): boolean => + requestToken !== null && localStorage.getItem('auth_token') === requestToken; + /** The session an operation started under. See `isSameAuthContext`. */ export type AuthContext = { token: string | null; diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts index 2cf722b120c..313b19e2d3e 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts @@ -1,6 +1,9 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; +import { configureStore } from '@reduxjs/toolkit'; +import type { BaseQueryApi } from '@reduxjs/toolkit/query'; +import { sessionExpiredLogout } from 'features/auth/store/authSlice'; import { toast } from 'features/toast/toast'; import i18n from 'i18next'; import { @@ -17,7 +20,7 @@ import { import type { ImageDTO } from 'services/api/types'; import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { api } from '..'; +import { api, buildV1Url, dynamicBaseQuery } from '..'; vi.mock('features/toast/toast', () => ({ toast: vi.fn() })); vi.mock('i18next', () => ({ default: { t: vi.fn((key: string) => key) } })); @@ -961,3 +964,141 @@ describe('imageDTOsByNamesQueryFn', () => { expect(await result).toEqual({ error: { status: 'CUSTOM_ERROR', error: expect.stringContaining('Aborted') } }); }); }); + +describe('unauthorized responses', () => { + // `getDeploymentBaseUrl` reads `window.location.origin`, and these are the only tests in this + // file that issue a real request rather than driving a queryFn with a mocked baseQuery. + beforeAll(() => { + vi.stubGlobal('window', { location: { origin: 'http://localhost' } }); + }); + + /** One real request through `dynamicBaseQuery`, with the server's answer staged by `respond`. */ + const request = async (respond: () => Response) => { + const dispatch = vi.fn(); + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve(respond())) + ); + const result = await dynamicBaseQuery( + buildV1Url('images/i/a.png'), + { + dispatch, + getState: () => ({}), + signal: new AbortController().signal, + abort: () => {}, + endpoint: 'getImageDTO', + type: 'query', + forced: false, + extra: undefined, + } as unknown as BaseQueryApi, + {} + ); + return { dispatch, result: result as { error?: { status?: unknown } } }; + }; + + it('ends the session when the token that got the 401 is still the live one', async () => { + login('user-a'); + + const { dispatch, result } = await request(() => new Response(null, { status: 401 })); + + expect(result.error?.status).toBe(401); + expect(dispatch).toHaveBeenCalledWith(sessionExpiredLogout()); + }); + + it('does not end the session that replaced the one the 401 belongs to', async () => { + // A's request is slow. While it is in flight B takes over the tab -- a login here, or one in + // another tab, which lands the same way because localStorage is shared. Then A's 401 arrives. + // Ending the session on it logs out B, who never issued the request and whose own credential + // the server never rejected. + login('user-a'); + + const { dispatch, result } = await request(() => { + switchUser('user-b'); + return new Response(null, { status: 401 }); + }); + + // The 401 is still reported to the caller -- that request did fail. What must not happen is + // the session-wide consequence. + expect(result.error?.status).toBe(401); + expect(dispatch).not.toHaveBeenCalled(); + expect(localStorage.getItem('auth_token')).toBe(tokenFor('user-b')); + }); + + it('does not end a session over a 401 for the token a refresh replaced', async () => { + // Same user throughout: the sliding window minted a new token mid-request. The old token's + // 401 says nothing about the new one, and if the session really is over the next request + // carries the live token and its 401 ends it. + login('user-a', 1); + + const { dispatch } = await request(() => { + login('user-a', 2); + return new Response(null, { status: 401 }); + }); + + expect(dispatch).not.toHaveBeenCalled(); + expect(localStorage.getItem('auth_token')).toBe(tokenFor('user-a', 2)); + }); + + it('leaves a 401 on an unauthenticated request alone', async () => { + // No token was sent, so nothing about a session was disproved. These fire during page load. + const { dispatch } = await request(() => new Response(null, { status: 401 })); + + expect(dispatch).not.toHaveBeenCalled(); + }); +}); + +describe('star invalidation', () => { + beforeAll(() => { + vi.stubGlobal('window', { location: { origin: 'http://localhost' } }); + }); + + const dtoUrl = `http://localhost/${buildV1Url('images/i/a.png')}`; + + /** A store holding just the API slice: enough for tag invalidation to drive a refetch. */ + const buildStore = () => + configureStore({ + reducer: { [api.reducerPath]: api.reducer }, + middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(api.middleware), + }); + + type ApiStore = ReturnType; + + it.each([ + { + label: 'star', + body: { starred_images: [], failed_images: ['a.png'], affected_boards: [] }, + mutate: (store: ApiStore) => store.dispatch(imagesApi.endpoints.starImages.initiate({ image_names: ['a.png'] })), + }, + { + label: 'unstar', + body: { unstarred_images: [], failed_images: ['a.png'], affected_boards: [] }, + mutate: (store: ApiStore) => + store.dispatch(imagesApi.endpoints.unstarImages.initiate({ image_names: ['a.png'] })), + }, + ])('refetches an image whose $label the server could not confirm', async ({ body, mutate }) => { + // `ImageService.update` writes the record and then reads the DTO back to return it. A + // failure in that read reports the name in `failed_images` with the row already starred, so + // the client's cached DTO is now wrong and nothing else will ever contradict it. Invalidating + // only the successes leaves the gallery showing the old star until a full reload. + login('user-a'); + const fetched: string[] = []; + vi.stubGlobal( + 'fetch', + vi.fn((request: Request) => { + fetched.push(request.url); + const payload = request.method === 'POST' ? body : { image_name: 'a.png', starred: false }; + return Promise.resolve( + new Response(JSON.stringify(payload), { headers: { 'content-type': 'application/json' } }) + ); + }) + ); + const store = buildStore(); + + await store.dispatch(imagesApi.endpoints.getImageDTO.initiate('a.png')); + expect(fetched.filter((url) => url === dtoUrl)).toHaveLength(1); + + await mutate(store); + + await vi.waitFor(() => expect(fetched.filter((url) => url === dtoUrl)).toHaveLength(2)); + }); +}); diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index 1d4f2fc4c5f..5fc9de6d375 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -320,16 +320,27 @@ const getDeleteImagesTags = (result: components['schemas']['DeleteImagesResult'] { type: 'ImageCollection', id: LIST_TAG }, ]; +/** + * A failed name is refetched, not ignored — the star routes can fail *after* committing. + * `ImageService.update` writes the record and then reads the DTO back to return it; a failure in + * that read (or in the event emit that follows) leaves the row starred while the route reports + * the name in `failed_images`. Invalidating only the successes would leave the client showing + * the pre-star value indefinitely, since nothing else will contradict it. Names folded in + * client-side for chunks that never went out ride along; their refetch merely confirms the cache. + */ const getStarImagesTags = (result: components['schemas']['StarredImagesResult']): InvalidateTagsArg => [ ...getTagsToInvalidateForImageMutation(result.starred_images), + ...getTagsToInvalidateForImageMutation(result.failed_images), ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards), 'ImageCollectionCounts', { type: 'ImageCollection', id: 'starred' }, { type: 'ImageCollection', id: 'unstarred' }, ]; +/** See `getStarImagesTags` for why the failed names are invalidated too. */ const getUnstarImagesTags = (result: components['schemas']['UnstarredImagesResult']): InvalidateTagsArg => [ ...getTagsToInvalidateForImageMutation(result.unstarred_images), + ...getTagsToInvalidateForImageMutation(result.failed_images), ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards), 'ImageCollectionCounts', { type: 'ImageCollection', id: 'starred' }, diff --git a/invokeai/frontend/web/src/services/api/index.ts b/invokeai/frontend/web/src/services/api/index.ts index 92832823c16..36574d66723 100644 --- a/invokeai/frontend/web/src/services/api/index.ts +++ b/invokeai/frontend/web/src/services/api/index.ts @@ -17,6 +17,7 @@ import { MEDIA_COOKIE_SYNC_TIMEOUT_MS, runWithMediaAuthLock, shouldAcceptRefreshedToken, + shouldEndSessionForUnauthorized, } from 'features/auth/store/authTokenRefresh'; import queryString from 'query-string'; import stableHash from 'stable-hash'; @@ -93,7 +94,7 @@ export const getBaseUrl = (): string => { return getDeploymentBaseUrl(); }; -const dynamicBaseQuery: BaseQueryFn = async ( +export const dynamicBaseQuery: BaseQueryFn = async ( args, api, extraOptions @@ -138,10 +139,12 @@ const dynamicBaseQuery: BaseQueryFn rawBaseQuery(args, api, extraOptions); const result = changesMediaCookie ? await runWithMediaAuthLock(execute) : await execute(); - // If we sent an auth token but got 401, the token is invalid/expired. - // Only trigger session expiry when we actually sent a token — unauthenticated - // requests (e.g. client_state queries during page load) should not cause logout. - if (result.error && result.error.status === 401 && !isAuthEndpoint && token) { + // If we sent an auth token but got 401, the token is invalid/expired. Only trigger session + // expiry when we actually sent a token — unauthenticated requests (e.g. client_state queries + // during page load) should not cause logout — and only while that token is still the live one, + // so a slow request cannot log out the session that replaced its own. See + // `shouldEndSessionForUnauthorized`. + if (result.error && result.error.status === 401 && !isAuthEndpoint && shouldEndSessionForUnauthorized(token)) { api.dispatch(sessionExpiredLogout()); } From 64fdf29311fad4decb4a225e3e3d7577503d5b98 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sun, 23 Aug 2026 22:16:08 -0400 Subject: [PATCH 29/34] fix(ui): close the paths the self-review found around the new 401 guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things the adversarial pass turned up. The blocker was only half fixed. `ProtectedRoute` ends the session on a 401 from `getCurrentUser` with no freshness check at all, and `sessionExpiredLogout` removes `auth_token` from localStorage — which is shared across tabs. So the same stale 401 still deleted the replacement session's credential: the query goes out during page load carrying an expired token, another tab logs in, and the 401 lands before the adoption poll runs. Both sites now ask the same predicate. Invalidating `failed_images` on star/unstar hands three components a new way to discard the user's input. A node image field and both reference-image components clear their value on ANY query error, so the refetch that the new invalidation triggers can silently drop a workflow input — and the refetch is likelier than usual to fail, because a name is in `failed_images` precisely when a storage failure interrupted its write. Only a 404 proves the image is gone; the video field was narrowed this way already, and the image side now has the same predicate. The new guard also let an empty-string token qualify, which sets no Authorization header and so proves nothing about any session. --- .../auth/components/ProtectedRoute.tsx | 16 +++++++- .../auth/store/authTokenRefresh.test.ts | 37 +++++++++++++++++++ .../features/auth/store/authTokenRefresh.ts | 9 +++-- .../components/RefImage/RefImageImage.tsx | 11 +++++- .../RegionalGuidanceRefImageImage.tsx | 10 +++-- .../inputs/ImageFieldInputComponent.tsx | 10 +++-- .../src/services/api/endpoints/images.test.ts | 4 +- .../web/src/services/api/endpoints/images.ts | 14 ++++--- .../src/services/api/util/imageErrors.test.ts | 23 ++++++++++++ .../web/src/services/api/util/imageErrors.ts | 18 +++++++++ 10 files changed, 133 insertions(+), 19 deletions(-) create mode 100644 invokeai/frontend/web/src/services/api/util/imageErrors.test.ts create mode 100644 invokeai/frontend/web/src/services/api/util/imageErrors.ts diff --git a/invokeai/frontend/web/src/features/auth/components/ProtectedRoute.tsx b/invokeai/frontend/web/src/features/auth/components/ProtectedRoute.tsx index dd0db7d9b0b..ed4b0f0a692 100644 --- a/invokeai/frontend/web/src/features/auth/components/ProtectedRoute.tsx +++ b/invokeai/frontend/web/src/features/auth/components/ProtectedRoute.tsx @@ -7,6 +7,7 @@ import { setCredentials, staleCredentialsDiscarded, } from 'features/auth/store/authSlice'; +import { shouldEndSessionForUnauthorized } from 'features/auth/store/authTokenRefresh'; import type { PropsWithChildren } from 'react'; import { memo, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -41,11 +42,24 @@ export const ProtectedRoute = memo(({ children, requireAdmin = false }: PropsWit // Only treat 401 as session expiry. Other errors (500, network, etc.) are // transient and should not force logout — the 401 handler in dynamicBaseQuery // already covers the actual expiry case. + // + // And only while this tab's token is still the live one. `sessionExpiredLogout` removes + // `auth_token` from localStorage, which is SHARED across tabs, so acting on a 401 that + // belongs to a superseded session deletes the credential of the session that replaced it: + // this query goes out during page load carrying an expired token, another tab logs in + // meanwhile, and this 401 lands before the adoption poll has run. `dynamicBaseQuery` + // already declines to end the session in that case; this is the same decision, and both + // ask `shouldEndSessionForUnauthorized`. Reading the token out of the store rather than + // out of the request is sound because the two can only diverge via `externalTokenAdopted`, + // whose foreign-token branch resets the API state and takes this error with it. if (userError && isAuthenticated && 'status' in userError && userError.status === 401) { + if (!shouldEndSessionForUnauthorized(token ?? null)) { + return; + } dispatch(sessionExpiredLogout()); navigate('/login', { replace: true }); } - }, [userError, isAuthenticated, dispatch, navigate]); + }, [userError, isAuthenticated, token, dispatch, navigate]); // Detect when auth_token is removed from localStorage (e.g. by another tab, // browser devtools, or token expiry cleanup). The 'storage' event fires when diff --git a/invokeai/frontend/web/src/features/auth/store/authTokenRefresh.test.ts b/invokeai/frontend/web/src/features/auth/store/authTokenRefresh.test.ts index ed8530ba217..e1e99c97792 100644 --- a/invokeai/frontend/web/src/features/auth/store/authTokenRefresh.test.ts +++ b/invokeai/frontend/web/src/features/auth/store/authTokenRefresh.test.ts @@ -6,6 +6,7 @@ import { createMediaAuthLock, runWithMediaAuthLock, shouldAcceptRefreshedToken, + shouldEndSessionForUnauthorized, } from './authTokenRefresh'; describe('refreshed token acceptance', () => { @@ -117,3 +118,39 @@ describe('refreshed token acceptance', () => { await expect(lock(() => 'next write')).resolves.toBe('next write'); }); }); + +describe('ending a session over a 401', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('ends it when the token that got the 401 is still the live one', () => { + localStorage.setItem('auth_token', 'token-a'); + + expect(shouldEndSessionForUnauthorized('token-a')).toBe(true); + }); + + it('spares the session that replaced the one the 401 belongs to', () => { + // Someone else took the tab over while the request was in flight -- here, or in another + // tab, since localStorage is shared. Their token must survive a stranger's 401. + localStorage.setItem('auth_token', 'token-b'); + + expect(shouldEndSessionForUnauthorized('token-a')).toBe(false); + }); + + it('spares a session whose token a sliding-window refresh replaced', () => { + // Byte equality, not `isSameAuthContext`: the refreshed token is the same login, but a 401 + // for the token it replaced says nothing about it. The next request settles the question. + localStorage.setItem('auth_token', 'token-a-refreshed'); + + expect(shouldEndSessionForUnauthorized('token-a')).toBe(false); + }); + + it('ignores a 401 for a request that carried no credential', () => { + // Both forms `dynamicBaseQuery` can hold: no token at all, and the empty string, which + // sets no Authorization header yet compares equal to itself once stored. + expect(shouldEndSessionForUnauthorized(null)).toBe(false); + localStorage.setItem('auth_token', ''); + expect(shouldEndSessionForUnauthorized('')).toBe(false); + }); +}); diff --git a/invokeai/frontend/web/src/features/auth/store/authTokenRefresh.ts b/invokeai/frontend/web/src/features/auth/store/authTokenRefresh.ts index 45c1d69784e..15f5be32850 100644 --- a/invokeai/frontend/web/src/features/auth/store/authTokenRefresh.ts +++ b/invokeai/frontend/web/src/features/auth/store/authTokenRefresh.ts @@ -61,11 +61,14 @@ export const shouldAcceptRefreshedToken = (requestToken: string, requestGenerati * costs nothing, because the session ending is not a one-shot event to be caught — if it really * has ended, the next request carries the live token and its 401 ends it here. * - * A null `requestToken` never qualifies: unauthenticated requests (client_state probes during - * page load, the setup-status query) 401 routinely and must not log anyone out. + * A falsy `requestToken` never qualifies: unauthenticated requests (client_state probes during + * page load, the setup-status query) 401 routinely and must not log anyone out. Truthiness + * rather than a null check, to match the condition `dynamicBaseQuery` actually sends the header + * under — an empty-string token sets no `Authorization` header, so its 401 proves nothing about + * any session, yet it is equal to the stored empty string. */ export const shouldEndSessionForUnauthorized = (requestToken: string | null): boolean => - requestToken !== null && localStorage.getItem('auth_token') === requestToken; + !!requestToken && localStorage.getItem('auth_token') === requestToken; /** The session an operation started under. See `isSameAuthContext`. */ export type AuthContext = { diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageImage.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageImage.tsx index a754f0e4da4..63abecdbf6f 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageImage.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageImage.tsx @@ -21,6 +21,7 @@ import { useTranslation } from 'react-i18next'; import { PiArrowCounterClockwiseBold, PiCropBold, PiRulerBold } from 'react-icons/pi'; import { useGetImageDTOQuery, useUploadImageMutation } from 'services/api/endpoints/images'; import type { ImageDTO } from 'services/api/types'; +import { isImageMissingError } from 'services/api/util/imageErrors'; import { $isConnected } from 'services/events/stores'; type Props = { @@ -56,10 +57,16 @@ export const RefImageImage = memo( }, [onChangeImage]); useEffect(() => { - if ((isConnected && croppedImageDTOReq.isError) || originalImageDTOReq.isError) { + // Only a 404 clears the reference. Any other error leaves it alone: a 5xx or a dropped + // connection says nothing about whether the image exists, and this reset is silent and + // has no undo. See `isImageMissingError`. + if ( + (isConnected && isImageMissingError(croppedImageDTOReq.error)) || + isImageMissingError(originalImageDTOReq.error) + ) { handleResetControlImage(); } - }, [handleResetControlImage, isConnected, croppedImageDTOReq.isError, originalImageDTOReq.isError]); + }, [handleResetControlImage, isConnected, croppedImageDTOReq.error, originalImageDTOReq.error]); const onUpload = useCallback( (imageDTO: ImageDTO) => { diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceRefImageImage.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceRefImageImage.tsx index 85285dd4ef3..d872f875dbe 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceRefImageImage.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceRefImageImage.tsx @@ -17,6 +17,7 @@ import { useTranslation } from 'react-i18next'; import { PiArrowCounterClockwiseBold, PiRulerBold } from 'react-icons/pi'; import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import type { ImageDTO } from 'services/api/types'; +import { isImageMissingError } from 'services/api/util/imageErrors'; import { $isConnected } from 'services/events/stores'; type Props = { @@ -32,16 +33,19 @@ export const RegionalGuidanceRefImageImage = memo(({ image, onChangeImage, dndTa const isConnected = useStore($isConnected); const tab = useAppSelector(selectActiveTab); const isStaging = useCanvasIsStaging(); - const { currentData: imageDTO, isError } = useGetImageDTOQuery(image?.image_name ?? skipToken); + const { currentData: imageDTO, error } = useGetImageDTOQuery(image?.image_name ?? skipToken); const handleResetControlImage = useCallback(() => { onChangeImage(null); }, [onChangeImage]); useEffect(() => { - if (isConnected && isError) { + // Only a 404 clears the reference. Any other error leaves it alone: a 5xx or a dropped + // connection says nothing about whether the image exists, and this reset is silent and has + // no undo. See `isImageMissingError`. + if (isConnected && isImageMissingError(error)) { handleResetControlImage(); } - }, [handleResetControlImage, isError, isConnected]); + }, [handleResetControlImage, error, isConnected]); const onUpload = useCallback( (imageDTO: ImageDTO) => { diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/ImageFieldInputComponent.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/ImageFieldInputComponent.tsx index 27c5d02625c..018bee8395b 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/ImageFieldInputComponent.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/ImageFieldInputComponent.tsx @@ -14,6 +14,7 @@ import { memo, useCallback, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import type { ImageDTO } from 'services/api/types'; +import { isImageMissingError } from 'services/api/util/imageErrors'; import { $isConnected } from 'services/events/stores'; import type { FieldComponentProps } from './types'; @@ -24,7 +25,7 @@ const ImageFieldInputComponent = (props: FieldComponentProps { dispatch( fieldImageValueChanged({ @@ -45,10 +46,13 @@ const ImageFieldInputComponent = (props: FieldComponentProps { - if (isConnected && isError) { + // Only a 404 clears the field. Any other error leaves it alone: a 5xx or a dropped + // connection says nothing about whether the image exists, and this reset is silent and + // has no undo. See `isImageMissingError`. + if (isConnected && isImageMissingError(error)) { handleReset(); } - }, [handleReset, isConnected, isError]); + }, [handleReset, isConnected, error]); const onUpload = useCallback( (imageDTO: ImageDTO) => { diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts index 313b19e2d3e..fccce5cec64 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts @@ -1020,8 +1020,9 @@ describe('unauthorized responses', () => { // The 401 is still reported to the caller -- that request did fail. What must not happen is // the session-wide consequence. expect(result.error?.status).toBe(401); + // The dispatch is the whole assertion: `sessionExpiredLogout`'s reducer is what removes + // B's token from localStorage, so not dispatching it is exactly "B stays authenticated". expect(dispatch).not.toHaveBeenCalled(); - expect(localStorage.getItem('auth_token')).toBe(tokenFor('user-b')); }); it('does not end a session over a 401 for the token a refresh replaced', async () => { @@ -1036,7 +1037,6 @@ describe('unauthorized responses', () => { }); expect(dispatch).not.toHaveBeenCalled(); - expect(localStorage.getItem('auth_token')).toBe(tokenFor('user-a', 2)); }); it('leaves a 401 on an unauthenticated request alone', async () => { diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index 5fc9de6d375..d74afbba535 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -170,10 +170,11 @@ const fetchChunk = async ( const response = await baseQuery(args); // An error is triaged like a success, but only for takeover. Mere expiry passes through // untriaged: this chunk's own expired-session 401 is the everyday case — `dynamicBaseQuery` - // dispatches `sessionExpiredLogout` before returning it, so the token is already gone by - // this line, and a rewrite would turn the ordinary failure into an abort that discards the - // committed chunks' report. A takeover, though, must not reach the loops' error handling at - // all: that handling *consumes* — it dispatches as-if-committed invalidations and returns + // dispatches `sessionExpiredLogout` before returning it (whenever the token it carried is + // still the live one, which for this chunk's own request is the ordinary case), so the token + // is already gone by this line, and a rewrite would turn the ordinary failure into an abort + // that discards the committed chunks' report. A takeover, though, must not reach the loops' + // error handling at all: that handling *consumes* — it dispatches as-if-committed invalidations and returns // partial aggregates the UI applies — and everything it would consume belongs to the // session that started the run, not to whoever owns the tab now. if (response.error) { @@ -517,7 +518,10 @@ export const bulkDownloadQueryFn = async ( // The mismatch errors from the pre/post-request checks, AND any error response that came // back into a session that is no longer the one that asked — which is how this chunk's // OWN expired-session 401 arrives: `dynamicBaseQuery` dispatches `sessionExpiredLogout` - // before returning it, so the token is already gone. The mutating loops deliberately do + // before returning it, so the token is already gone. (It declines to when the token has + // since been replaced — a takeover, caught by the same check here, or a sliding-window + // refresh, where the session is alive and this chunk is an ordinary failed request.) + // The mutating loops deliberately do // NOT reclassify that 401 — they need it on the partial path, where the payload feeds // `handleDeletions` — but a download has no state work to salvage, and its usual outputs // are both wrong for a session that is ending: the failure count is a miscount at the diff --git a/invokeai/frontend/web/src/services/api/util/imageErrors.test.ts b/invokeai/frontend/web/src/services/api/util/imageErrors.test.ts new file mode 100644 index 00000000000..5d7e1dbfeac --- /dev/null +++ b/invokeai/frontend/web/src/services/api/util/imageErrors.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { isImageMissingError } from './imageErrors'; + +describe('isImageMissingError', () => { + it('is true for a 404 — the image is confirmed gone', () => { + expect(isImageMissingError({ status: 404, data: { detail: 'not found' } })).toBe(true); + }); + + it.each([ + ['auth (401)', { status: 401, data: {} }], + ['forbidden (403)', { status: 403, data: {} }], + // The one the star/unstar invalidation makes reachable: the name is in `failed_images` + // because a storage failure interrupted its write, and the refetch that reports the new + // state hits the same unwell store. + ['server error (500)', { status: 500, data: {} }], + ['network failure', { status: 'FETCH_ERROR', error: 'TypeError: Failed to fetch' }], + ['parsing failure', { status: 'PARSING_ERROR', originalStatus: 200, data: '', error: 'oops' }], + ['no error', undefined], + ])('is false for %s — the reference must be preserved', (_label, error) => { + expect(isImageMissingError(error)).toBe(false); + }); +}); diff --git a/invokeai/frontend/web/src/services/api/util/imageErrors.ts b/invokeai/frontend/web/src/services/api/util/imageErrors.ts new file mode 100644 index 00000000000..3c554389b54 --- /dev/null +++ b/invokeai/frontend/web/src/services/api/util/imageErrors.ts @@ -0,0 +1,18 @@ +/** + * True only for a confirmed "image does not exist" (HTTP 404) query error. + * + * Several components clear the user's input when the image they reference is gone — a node's + * image field, a global reference image, a regional guidance reference image. A transient + * network error (`FETCH_ERROR`), an auth failure (401/403), or a server error (5xx) proves + * nothing about whether the image still exists, and discarding the input over one loses work + * the user cannot recover: the field is cleared with no toast and no undo. + * + * That distinction became load-bearing when the star/unstar mutations began invalidating the + * DTOs of names the server reported in `failed_images`. Those names are reported precisely + * because a storage failure interrupted the write, so the refetch that the invalidation triggers + * is running against a store that is already unwell and is likelier than usual to answer 500. + * + * `isVideoMissingError` is the same predicate for videos, and exists for the same reason. + */ +export const isImageMissingError = (error: unknown): boolean => + error instanceof Object && 'status' in error && error.status === 404; From 50bf9c313a84c58fcae5b3f7baa06f9a73758332 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sun, 23 Aug 2026 22:24:26 -0400 Subject: [PATCH 30/34] fix(api,ui): let a client trust the 403 it drops an image reference on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Narrowing the components to a 404 was wrong on its own: a deleted image only answers 404 for an admin. `assert_image_read_access` decides on `images.user_id`, which is gone with the row, so in a multiuser deployment a deleted image is indistinguishable from someone else's and both are refused 403. Requiring 404 would have stranded every deleted image in the workflows referencing it, which is worse than the over-broad clear it replaced. So the clients act on 403 as well — and that puts an obligation on the answer. The read helper laundered every storage error from its board lookup into that same 403, so an unreadable database presented as a permission decision and would now take the user's references down with it. It reads the board record and catches only a positive not-found, matching what `assert_image_owner` already does on the mutation side; anything undecidable propagates. --- invokeai/app/api/routers/_access.py | 13 +++- .../components/RefImage/RefImageImage.tsx | 13 ++-- .../RegionalGuidanceRefImageImage.tsx | 11 +-- .../inputs/ImageFieldInputComponent.tsx | 11 +-- .../src/services/api/util/imageErrors.test.ts | 25 +++--- .../web/src/services/api/util/imageErrors.ts | 34 ++++---- .../routers/test_multiuser_authorization.py | 77 +++++++++++++++++++ 7 files changed, 143 insertions(+), 41 deletions(-) diff --git a/invokeai/app/api/routers/_access.py b/invokeai/app/api/routers/_access.py index 4ffa08fbe44..a9575d472b1 100644 --- a/invokeai/app/api/routers/_access.py +++ b/invokeai/app/api/routers/_access.py @@ -71,12 +71,19 @@ def assert_image_read_access(image_name: str, current_user: CurrentUserOrDefault board_id = ApiDependencies.invoker.services.board_image_records.get_board_for_image(image_name) if board_id is not None: + # See `assert_image_owner` for why this reads the board record and catches only + # not-found. The read side needs it for a second reason: this 403 is also the answer a + # *deleted* image gets, because the decision rests on `images.user_id` and that is gone + # with the row. Clients therefore have to read 403 as "this image is not available to + # me" and drop their reference to it — a workflow's image field clears itself on one. + # An unreadable database must not be able to produce that answer. try: - board = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id) + board = ApiDependencies.invoker.services.board_records.get(board_id) + except BoardRecordNotFoundException: + pass + else: if board.board_visibility in (BoardVisibility.Shared, BoardVisibility.Public): return - except Exception: - pass raise HTTPException(status_code=403, detail="Not authorized to access this image") diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageImage.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageImage.tsx index 63abecdbf6f..53bde6d3b0c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageImage.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageImage.tsx @@ -21,7 +21,7 @@ import { useTranslation } from 'react-i18next'; import { PiArrowCounterClockwiseBold, PiCropBold, PiRulerBold } from 'react-icons/pi'; import { useGetImageDTOQuery, useUploadImageMutation } from 'services/api/endpoints/images'; import type { ImageDTO } from 'services/api/types'; -import { isImageMissingError } from 'services/api/util/imageErrors'; +import { isImageUnavailableError } from 'services/api/util/imageErrors'; import { $isConnected } from 'services/events/stores'; type Props = { @@ -57,12 +57,13 @@ export const RefImageImage = memo( }, [onChangeImage]); useEffect(() => { - // Only a 404 clears the reference. Any other error leaves it alone: a 5xx or a dropped - // connection says nothing about whether the image exists, and this reset is silent and - // has no undo. See `isImageMissingError`. + // Cleared only when the server says the image is not available to this client (404, + // or the 403 a deleted image answers with in multiuser mode). Any other error leaves + // it alone: a 5xx or a dropped connection says nothing about whether the image + // exists, and this reset is silent and has no undo. See `isImageUnavailableError`. if ( - (isConnected && isImageMissingError(croppedImageDTOReq.error)) || - isImageMissingError(originalImageDTOReq.error) + (isConnected && isImageUnavailableError(croppedImageDTOReq.error)) || + isImageUnavailableError(originalImageDTOReq.error) ) { handleResetControlImage(); } diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceRefImageImage.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceRefImageImage.tsx index d872f875dbe..c00f1eef4d6 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceRefImageImage.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceRefImageImage.tsx @@ -17,7 +17,7 @@ import { useTranslation } from 'react-i18next'; import { PiArrowCounterClockwiseBold, PiRulerBold } from 'react-icons/pi'; import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import type { ImageDTO } from 'services/api/types'; -import { isImageMissingError } from 'services/api/util/imageErrors'; +import { isImageUnavailableError } from 'services/api/util/imageErrors'; import { $isConnected } from 'services/events/stores'; type Props = { @@ -39,10 +39,11 @@ export const RegionalGuidanceRefImageImage = memo(({ image, onChangeImage, dndTa }, [onChangeImage]); useEffect(() => { - // Only a 404 clears the reference. Any other error leaves it alone: a 5xx or a dropped - // connection says nothing about whether the image exists, and this reset is silent and has - // no undo. See `isImageMissingError`. - if (isConnected && isImageMissingError(error)) { + // Cleared only when the server says the image is not available to this client (404, + // or the 403 a deleted image answers with in multiuser mode). Any other error leaves + // it alone: a 5xx or a dropped connection says nothing about whether the image + // exists, and this reset is silent and has no undo. See `isImageUnavailableError`. + if (isConnected && isImageUnavailableError(error)) { handleResetControlImage(); } }, [handleResetControlImage, error, isConnected]); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/ImageFieldInputComponent.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/ImageFieldInputComponent.tsx index 018bee8395b..9383971b6af 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/ImageFieldInputComponent.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/ImageFieldInputComponent.tsx @@ -14,7 +14,7 @@ import { memo, useCallback, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import type { ImageDTO } from 'services/api/types'; -import { isImageMissingError } from 'services/api/util/imageErrors'; +import { isImageUnavailableError } from 'services/api/util/imageErrors'; import { $isConnected } from 'services/events/stores'; import type { FieldComponentProps } from './types'; @@ -46,10 +46,11 @@ const ImageFieldInputComponent = (props: FieldComponentProps { - // Only a 404 clears the field. Any other error leaves it alone: a 5xx or a dropped - // connection says nothing about whether the image exists, and this reset is silent and - // has no undo. See `isImageMissingError`. - if (isConnected && isImageMissingError(error)) { + // Cleared only when the server says the image is not available to this client (404, + // or the 403 a deleted image answers with in multiuser mode). Any other error leaves + // it alone: a 5xx or a dropped connection says nothing about whether the image + // exists, and this reset is silent and has no undo. See `isImageUnavailableError`. + if (isConnected && isImageUnavailableError(error)) { handleReset(); } }, [handleReset, isConnected, error]); diff --git a/invokeai/frontend/web/src/services/api/util/imageErrors.test.ts b/invokeai/frontend/web/src/services/api/util/imageErrors.test.ts index 5d7e1dbfeac..751296efc4d 100644 --- a/invokeai/frontend/web/src/services/api/util/imageErrors.test.ts +++ b/invokeai/frontend/web/src/services/api/util/imageErrors.test.ts @@ -1,23 +1,30 @@ import { describe, expect, it } from 'vitest'; -import { isImageMissingError } from './imageErrors'; +import { isImageUnavailableError } from './imageErrors'; -describe('isImageMissingError', () => { +describe('isImageUnavailableError', () => { it('is true for a 404 — the image is confirmed gone', () => { - expect(isImageMissingError({ status: 404, data: { detail: 'not found' } })).toBe(true); + expect(isImageUnavailableError({ status: 404, data: { detail: 'not found' } })).toBe(true); + }); + + it('is true for a 403 — which is how a deleted image answers in multiuser mode', () => { + // `assert_image_read_access` decides on `images.user_id`, and that row is gone, so it + // cannot tell a deleted image from someone else's. Only an admin gets as far as the 404. + // Requiring 404 here would leave every deleted image stuck in the workflows that use it. + expect(isImageUnavailableError({ status: 403, data: { detail: 'Not authorized' } })).toBe(true); }); it.each([ - ['auth (401)', { status: 401, data: {} }], - ['forbidden (403)', { status: 403, data: {} }], - // The one the star/unstar invalidation makes reachable: the name is in `failed_images` - // because a storage failure interrupted its write, and the refetch that reports the new - // state hits the same unwell store. + // The one the star/unstar invalidation makes reachable: a name is in `failed_images` + // because a storage failure interrupted its write, and the refetch that the invalidation + // triggers hits the same unwell store. ['server error (500)', { status: 500, data: {} }], + ['unauthorized (401)', { status: 401, data: {} }], ['network failure', { status: 'FETCH_ERROR', error: 'TypeError: Failed to fetch' }], + ['timeout', { status: 'TIMEOUT_ERROR', error: 'AbortError' }], ['parsing failure', { status: 'PARSING_ERROR', originalStatus: 200, data: '', error: 'oops' }], ['no error', undefined], ])('is false for %s — the reference must be preserved', (_label, error) => { - expect(isImageMissingError(error)).toBe(false); + expect(isImageUnavailableError(error)).toBe(false); }); }); diff --git a/invokeai/frontend/web/src/services/api/util/imageErrors.ts b/invokeai/frontend/web/src/services/api/util/imageErrors.ts index 3c554389b54..ad3e3d40bad 100644 --- a/invokeai/frontend/web/src/services/api/util/imageErrors.ts +++ b/invokeai/frontend/web/src/services/api/util/imageErrors.ts @@ -1,18 +1,26 @@ /** - * True only for a confirmed "image does not exist" (HTTP 404) query error. + * True when the server has answered that this client cannot have the image: it is gone, or it + * is not theirs to read. Both mean the reference is unusable and the component holding it + * should let it go. * - * Several components clear the user's input when the image they reference is gone — a node's - * image field, a global reference image, a regional guidance reference image. A transient - * network error (`FETCH_ERROR`), an auth failure (401/403), or a server error (5xx) proves - * nothing about whether the image still exists, and discarding the input over one loses work - * the user cannot recover: the field is cleared with no toast and no undo. + * Two statuses, because "deleted" does not always arrive as 404. `assert_image_read_access` + * decides on `images.user_id`, which disappears with the row, so in a multiuser deployment a + * deleted image is indistinguishable from someone else's and both are refused with 403 — only + * an admin (and so every single-user deployment, whose default user is one) reaches the read + * that 404s. Treating 404 alone as gone would strand a deleted image in every workflow field + * that references it. * - * That distinction became load-bearing when the star/unstar mutations began invalidating the - * DTOs of names the server reported in `failed_images`. Those names are reported precisely - * because a storage failure interrupted the write, so the refetch that the invalidation triggers - * is running against a store that is already unwell and is likelier than usual to answer 500. + * Everything else is indeterminate and must NOT discard the user's input. A transient network + * failure (`FETCH_ERROR`), a timeout, a parse failure or a 5xx says nothing about whether the + * image exists, and this reset is silent and has no undo. That distinction became load-bearing + * when the star/unstar mutations began invalidating the DTOs of names the server reported in + * `failed_images`: a name is reported there precisely because a storage failure interrupted its + * write, so the refetch the invalidation triggers is running against a store that is already + * unwell and is likelier than usual to answer 500. * - * `isVideoMissingError` is the same predicate for videos, and exists for the same reason. + * The 403 arm carries a matching obligation on the server, met in `assert_image_read_access`: + * a storage error must never be laundered into a 403, or an unreadable database would present + * as a permission decision and take the user's references down with it. */ -export const isImageMissingError = (error: unknown): boolean => - error instanceof Object && 'status' in error && error.status === 404; +export const isImageUnavailableError = (error: unknown): boolean => + error instanceof Object && 'status' in error && (error.status === 404 || error.status === 403); diff --git a/tests/app/routers/test_multiuser_authorization.py b/tests/app/routers/test_multiuser_authorization.py index 2a64275b908..b4c60b3f298 100644 --- a/tests/app/routers/test_multiuser_authorization.py +++ b/tests/app/routers/test_multiuser_authorization.py @@ -864,6 +864,83 @@ def test_get_image_dto_requires_auth(self, enable_multiuser: Any, client: TestCl r = client.get("/api/v1/images/i/some-image") assert r.status_code == status.HTTP_401_UNAUTHORIZED + def test_deleted_image_reads_as_unavailable_rather_than_undecidable( + self, client: TestClient, mock_invoker: Invoker, user1_token: str + ): + """A deleted image answers 403, and the clients depend on being able to trust it. + + The read decision rests on `images.user_id`, which is gone with the row, so a + non-admin cannot be told a deleted image from someone else's and both are refused + the same way. The frontend therefore drops its reference to an image on a 403 as + well as a 404 -- a workflow's image field clears itself on one. Pinned here because + that behaviour reads as over-broad without this route's answer to point at. + """ + user1 = mock_invoker.services.users.get_by_email("user1@test.com") + assert user1 is not None + _save_image(mock_invoker, "user1-doomed", user1.user_id) + mock_invoker.services.image_records.delete("user1-doomed") + + r = client.get("/api/v1/images/i/user1-doomed", headers=_auth(user1_token)) + + assert r.status_code == status.HTTP_403_FORBIDDEN + + def test_unreadable_board_does_not_read_as_unavailable( + self, client: TestClient, mock_invoker: Invoker, monkeypatch: Any, user1_token: str, user2_token: str + ): + """A storage error must not reach the client wearing the deleted image's answer. + + `assert_image_read_access` used to catch every exception from the board lookup and + fall through to the same 403 a deleted image gets. Since the clients read that 403 as + "gone, drop your reference", a locked database would have taken every workflow field + and reference image pointing at a shared board's images down with it. Only a board + positively known to be gone may still answer 403. + """ + import sqlite3 + + user1 = mock_invoker.services.users.get_by_email("user1@test.com") + assert user1 is not None + board_id = _create_board(client, user1_token, "User1 Shared Read Board") + _set_board_visibility(client, user1_token, board_id, "shared") + _save_image(mock_invoker, "user1-shared-read", user1.user_id) + mock_invoker.services.board_image_records.add_image_to_board(board_id, "user1-shared-read") + + # Patched only after the setup above used the real store. user2 is neither admin nor + # direct owner, so the decision reaches the board lookup and cannot complete. + monkeypatch.setattr( + mock_invoker.services.board_records, + "get", + MagicMock(side_effect=sqlite3.OperationalError("database is locked")), + ) + + # The storage error leaves the route uncaught, which is a 500 in production; the test + # client re-raises unhandled server exceptions instead of rendering them. Either way the + # one thing that must not happen is a 403 -- the answer the clients act on destructively. + with pytest.raises(sqlite3.OperationalError): + client.get("/api/v1/images/i/user1-shared-read", headers=_auth(user2_token)) + + def test_vanished_board_still_reads_as_an_ordinary_refusal( + self, client: TestClient, mock_invoker: Invoker, monkeypatch: Any, user1_token: str, user2_token: str + ): + """The narrowed catch stays exactly that narrow, in both directions.""" + from invokeai.app.services.board_records.board_records_common import BoardRecordNotFoundException + + user1 = mock_invoker.services.users.get_by_email("user1@test.com") + assert user1 is not None + board_id = _create_board(client, user1_token, "User1 Vanishing Read Board") + _set_board_visibility(client, user1_token, board_id, "shared") + _save_image(mock_invoker, "user1-read-board-gone", user1.user_id) + mock_invoker.services.board_image_records.add_image_to_board(board_id, "user1-read-board-gone") + + monkeypatch.setattr( + mock_invoker.services.board_records, + "get", + MagicMock(side_effect=BoardRecordNotFoundException), + ) + + r = client.get("/api/v1/images/i/user1-read-board-gone", headers=_auth(user2_token)) + + assert r.status_code == status.HTTP_403_FORBIDDEN + def test_get_image_metadata_requires_auth(self, enable_multiuser: Any, client: TestClient): r = client.get("/api/v1/images/i/some-image/metadata") assert r.status_code == status.HTTP_401_UNAUTHORIZED From e97dc0bee93b70922e7dc73408c09c45b6b96740 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sun, 23 Aug 2026 22:43:15 -0400 Subject: [PATCH 31/34] fix(api,ui): key the identity query by its token, and give videos the same answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `ProtectedRoute` guard only postponed the logout it was meant to prevent. The store's token catches up when the poll adopts the new one, the effect re-runs, and the superseded 401 is still sitting in the cache: `getCurrentUser` is shared across logins, its argument never changes, it carries no tags, and the API-state reset a login normally brings is deliberately skipped when the new token belongs to the same user. On the ordering where the storage event beats the response — the common one, since it is delivered without a network round trip — the guard never even delayed it. Keyed by the token instead, so the adopted session reads its own entry and the superseded 401 is no longer in hand to act on. The argument is never sent — the token goes in a header — but it is what the answer is about, and the call site now cannot forget to pass it: the endpoint's argument type is what enforces it. Videos get the read-side pair the images just got. Trusting a 403 as "gone" is what lets a deleted item clear itself, and `isVideoMissingError` accepted only 404 — which no non-admin ever sees for a deleted video — so a deleted video would have stayed pinned in a workflow field forever. The video read helper stops laundering storage errors into that same 403. --- invokeai/app/api/routers/videos.py | 17 +++- .../auth/components/ProtectedRoute.tsx | 12 ++- .../inputs/VideoFieldInputComponent.tsx | 11 +-- .../fields/inputs/videoFieldErrors.test.ts | 17 ++-- .../fields/inputs/videoFieldErrors.ts | 26 ++++-- .../src/services/api/endpoints/auth.test.ts | 80 +++++++++++++++++++ .../web/src/services/api/endpoints/auth.ts | 15 +++- tests/app/routers/test_videos_multiuser.py | 63 +++++++++++++++ 8 files changed, 216 insertions(+), 25 deletions(-) create mode 100644 invokeai/frontend/web/src/services/api/endpoints/auth.test.ts diff --git a/invokeai/app/api/routers/videos.py b/invokeai/app/api/routers/videos.py index 0604befb25c..41a70e978f0 100644 --- a/invokeai/app/api/routers/videos.py +++ b/invokeai/app/api/routers/videos.py @@ -143,7 +143,10 @@ def _assert_board_write_access(board_id: str, current_user: CurrentUserOrDefault def _assert_video_read_access(video_name: str, current_user: CurrentUserOrDefault) -> None: """Raise 403 if the current user may not view the video.""" - from invokeai.app.services.board_records.board_records_common import BoardVisibility + from invokeai.app.services.board_records.board_records_common import ( + BoardRecordNotFoundException, + BoardVisibility, + ) if current_user.is_admin: return @@ -153,12 +156,18 @@ def _assert_video_read_access(video_name: str, current_user: CurrentUserOrDefaul board_id = ApiDependencies.invoker.services.board_video_records.get_board_for_video(video_name) if board_id is not None: + # See `assert_image_read_access`: this 403 is also the answer a *deleted* video gets, + # because the decision rests on a `user_id` that is gone with the row, so clients act on + # it by dropping their reference to the video. Only a board positively known to be gone + # may produce it; a lookup that cannot be decided propagates instead of impersonating a + # permission decision. try: - board = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id) + board = ApiDependencies.invoker.services.board_records.get(board_id) + except BoardRecordNotFoundException: + pass + else: if board.board_visibility in (BoardVisibility.Shared, BoardVisibility.Public): return - except Exception: - pass raise HTTPException(status_code=403, detail="Not authorized to access this video") diff --git a/invokeai/frontend/web/src/features/auth/components/ProtectedRoute.tsx b/invokeai/frontend/web/src/features/auth/components/ProtectedRoute.tsx index ed4b0f0a692..c82d355429e 100644 --- a/invokeai/frontend/web/src/features/auth/components/ProtectedRoute.tsx +++ b/invokeai/frontend/web/src/features/auth/components/ProtectedRoute.tsx @@ -1,4 +1,5 @@ import { Center, Spinner } from '@invoke-ai/ui-library'; +import { skipToken } from '@reduxjs/toolkit/query'; import type { RootState } from 'app/store/store'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { @@ -34,7 +35,7 @@ export const ProtectedRoute = memo(({ children, requireAdmin = false }: PropsWit data: currentUser, isLoading: isLoadingUser, error: userError, - } = useGetCurrentUserQuery(undefined, { + } = useGetCurrentUserQuery(token ?? skipToken, { skip: !shouldFetchUser, }); @@ -49,9 +50,12 @@ export const ProtectedRoute = memo(({ children, requireAdmin = false }: PropsWit // this query goes out during page load carrying an expired token, another tab logs in // meanwhile, and this 401 lands before the adoption poll has run. `dynamicBaseQuery` // already declines to end the session in that case; this is the same decision, and both - // ask `shouldEndSessionForUnauthorized`. Reading the token out of the store rather than - // out of the request is sound because the two can only diverge via `externalTokenAdopted`, - // whose foreign-token branch resets the API state and takes this error with it. + // ask `shouldEndSessionForUnauthorized`. + // + // That check alone would only postpone it, because the store's token catches up when the + // poll adopts the new one and this effect re-runs. What stops it then is the query being + // keyed by token: the adopted session reads its own cache entry, so the superseded 401 is + // no longer in hand to act on. See `getCurrentUser`. if (userError && isAuthenticated && 'status' in userError && userError.status === 401) { if (!shouldEndSessionForUnauthorized(token ?? null)) { return; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/VideoFieldInputComponent.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/VideoFieldInputComponent.tsx index cb3cdf6b0fa..c321ddbd940 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/VideoFieldInputComponent.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/VideoFieldInputComponent.tsx @@ -16,7 +16,7 @@ import { useGetVideoDTOQuery } from 'services/api/endpoints/videos'; import { $isConnected } from 'services/events/stores'; import type { FieldComponentProps } from './types'; -import { isVideoMissingError } from './videoFieldErrors'; +import { isVideoUnavailableError } from './videoFieldErrors'; /** * Counterpart to ImageFieldInputComponent for VideoField inputs. Shows the video's WebP @@ -48,11 +48,12 @@ const VideoFieldInputComponent = (props: FieldComponentProps { - if (isConnected && isVideoMissingError(error)) { + if (isConnected && isVideoUnavailableError(error)) { handleReset(); } }, [handleReset, isConnected, error]); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/videoFieldErrors.test.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/videoFieldErrors.test.ts index d574e17ffd8..d4f95526afc 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/videoFieldErrors.test.ts +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/videoFieldErrors.test.ts @@ -1,20 +1,27 @@ import { describe, expect, it } from 'vitest'; -import { isVideoMissingError } from './videoFieldErrors'; +import { isVideoUnavailableError } from './videoFieldErrors'; -describe('isVideoMissingError', () => { +describe('isVideoUnavailableError', () => { it('is true for a 404 — the video is confirmed gone', () => { - expect(isVideoMissingError({ status: 404, data: { detail: 'not found' } })).toBe(true); + expect(isVideoUnavailableError({ status: 404, data: { detail: 'not found' } })).toBe(true); + }); + + it('is true for a 403 — which is how a deleted video answers in multiuser mode', () => { + // `_assert_video_read_access` decides on `videos.user_id`, and that row is gone, so it + // cannot tell a deleted video from someone else's. Only an admin gets as far as the 404. + // Requiring 404 here would leave every deleted video stuck in the workflows that use it. + expect(isVideoUnavailableError({ status: 403, data: { detail: 'Not authorized' } })).toBe(true); }); it.each([ ['auth (401)', { status: 401, data: {} }], - ['forbidden (403)', { status: 403, data: {} }], ['server error (500)', { status: 500, data: {} }], ['network failure', { status: 'FETCH_ERROR', error: 'TypeError: Failed to fetch' }], + ['timeout', { status: 'TIMEOUT_ERROR', error: 'AbortError' }], ['parsing failure', { status: 'PARSING_ERROR', originalStatus: 200, data: '', error: 'oops' }], ['no error', undefined], ])('is false for %s — the field value must be preserved', (_label, error) => { - expect(isVideoMissingError(error)).toBe(false); + expect(isVideoUnavailableError(error)).toBe(false); }); }); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/videoFieldErrors.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/videoFieldErrors.ts index c55f160b96c..d76b3c8b23c 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/videoFieldErrors.ts +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/videoFieldErrors.ts @@ -1,9 +1,23 @@ /** - * True only for a confirmed "video does not exist" (HTTP 404) query error. + * True when the server has answered that this client cannot have the video: it is gone, or it + * is not theirs to read. Both mean the reference is unusable and the field holding it should + * let it go. * - * VideoFieldInputComponent clears its field value when the referenced video is gone, but - * a transient network error (`FETCH_ERROR`), auth failure (401/403), or server error (5xx) - * must not silently discard the user's input — only a 404 proves the video was deleted. + * Two statuses, because "deleted" does not always arrive as 404. `_assert_video_read_access` + * decides on `videos.user_id`, which disappears with the row, so in a multiuser deployment a + * deleted video is indistinguishable from someone else's and both are refused with 403 — only + * an admin (and so every single-user deployment, whose default user is one) reaches the read + * that 404s. Treating 404 alone as gone would strand a deleted video in every workflow field + * that references it. + * + * Everything else is indeterminate and must NOT discard the user's input: a transient network + * error (`FETCH_ERROR`), an auth failure (401), a timeout or a 5xx says nothing about whether + * the video exists, and the reset is silent and has no undo. + * + * The 403 arm carries a matching obligation on the server, met in `_assert_video_read_access`: + * a storage error must never be laundered into a 403, or an unreadable database would present + * as a permission decision and take the user's references down with it. `isImageUnavailableError` + * is the same predicate for images. */ -export const isVideoMissingError = (error: unknown): boolean => - error instanceof Object && 'status' in error && error.status === 404; +export const isVideoUnavailableError = (error: unknown): boolean => + error instanceof Object && 'status' in error && (error.status === 404 || error.status === 403); diff --git a/invokeai/frontend/web/src/services/api/endpoints/auth.test.ts b/invokeai/frontend/web/src/services/api/endpoints/auth.test.ts new file mode 100644 index 00000000000..b3f35c79bc2 --- /dev/null +++ b/invokeai/frontend/web/src/services/api/endpoints/auth.test.ts @@ -0,0 +1,80 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { authApi } from 'services/api/endpoints/auth'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { api } from '..'; + +/** + * `dynamicBaseQuery` reads the bearer token out of localStorage, and `getDeploymentBaseUrl` + * reads `window.location.origin`. Neither exists in the default (node) test environment. + */ +beforeAll(() => { + const values = new Map(); + vi.stubGlobal('localStorage', { + clear: () => values.clear(), + getItem: (key: string) => values.get(key) ?? null, + key: (index: number) => [...values.keys()][index] ?? null, + get length() { + return values.size; + }, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + }); + vi.stubGlobal('window', { location: { origin: 'http://localhost' } }); +}); + +beforeEach(() => { + localStorage.clear(); +}); + +const buildStore = () => + configureStore({ + reducer: { [api.reducerPath]: api.reducer }, + middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(api.middleware), + }); + +describe('getCurrentUser', () => { + it('does not let a replacement session read the 401 of the token it replaced', async () => { + // The sequence this exists for: a tab page-loads with an expired token and asks who it is; + // another tab logs the same user back in, so localStorage now holds a new token and this tab + // adopts it — an adoption that deliberately keeps the API cache, since the user did not + // change. The first request's 401 arrives in between. Shared across logins, one cache entry + // would hand that 401 to the adopted session, and `ProtectedRoute` ends the session on a 401 + // from this query: the token the user just obtained would be deleted out of localStorage, + // taking the tab that minted it down too. + const requests: (string | null)[] = []; + vi.stubGlobal( + 'fetch', + vi.fn((request: Request) => { + const sent = request.headers.get('Authorization'); + requests.push(sent); + if (sent === 'Bearer token-expired') { + return Promise.resolve(new Response(null, { status: 401 })); + } + return Promise.resolve( + new Response(JSON.stringify({ user_id: 'user-1', email: 'user@example.com', is_admin: false }), { + headers: { 'content-type': 'application/json' }, + }) + ); + }) + ); + const store = buildStore(); + + localStorage.setItem('auth_token', 'token-expired'); + await store.dispatch(authApi.endpoints.getCurrentUser.initiate('token-expired')); + expect(authApi.endpoints.getCurrentUser.select('token-expired')(store.getState()).error).toBeDefined(); + + localStorage.setItem('auth_token', 'token-fresh'); + await store.dispatch(authApi.endpoints.getCurrentUser.initiate('token-fresh')); + + // A second request went out, under the new credential, and its result is what the adopted + // session reads. The superseded entry keeps its own 401 and is no longer anybody's answer. + expect(requests).toEqual(['Bearer token-expired', 'Bearer token-fresh']); + const fresh = authApi.endpoints.getCurrentUser.select('token-fresh')(store.getState()); + expect(fresh.error).toBeUndefined(); + expect(fresh.data).toMatchObject({ user_id: 'user-1' }); + // And the superseded entry is untouched, so this is a separate answer rather than one + // overwritten in place: an adopted session reads `token-fresh`'s and never sees the 401. + expect(authApi.endpoints.getCurrentUser.select('token-expired')(store.getState()).error).toBeDefined(); + }); +}); diff --git a/invokeai/frontend/web/src/services/api/endpoints/auth.ts b/invokeai/frontend/web/src/services/api/endpoints/auth.ts index dbbdf4b68f8..48e704eb557 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/auth.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/auth.ts @@ -103,7 +103,20 @@ export const authApi = api.injectEndpoints({ method: 'POST', }), }), - getCurrentUser: build.query({ + /** + * Keyed by the bearer token the request will carry, which is why it takes an argument it + * never sends: the token travels in the `Authorization` header, read out of localStorage at + * send time, but it is what the answer is *about*, so it belongs in the cache key. + * + * Shared across logins, one entry outlives the token that produced it. A 401 for a token + * that has since been replaced stays readable under its replacement — and `ProtectedRoute` + * ends the session on a 401 from this query, so it would end a session on a stranger's + * failure. Nothing refetches it on its own to correct that: the argument never changed, the + * endpoint has no tags, and the API-state reset that a login normally triggers is skipped + * when the new token belongs to the same user. Keyed by token, the replacement session + * simply reads a different entry, and asking for it is what fetches it. + */ + getCurrentUser: build.query({ query: () => 'api/v1/auth/me', }), setup: build.mutation({ diff --git a/tests/app/routers/test_videos_multiuser.py b/tests/app/routers/test_videos_multiuser.py index c0308c6e19c..bfe0444e14e 100644 --- a/tests/app/routers/test_videos_multiuser.py +++ b/tests/app/routers/test_videos_multiuser.py @@ -257,6 +257,69 @@ def test_delete_videos_from_list_dedupes_repeated_names(client: TestClient, mock assert sorted(delete_calls) == ["dup.mp4", "other.mp4"] +def test_deleted_video_reads_as_unavailable_rather_than_undecidable( + client: TestClient, mock_invoker: Invoker, user1_token: str +): + """A deleted video answers 403, and the clients depend on being able to trust it. + + The read decision rests on ``videos.user_id``, which is gone with the row, so a non-admin + cannot be told a deleted video from someone else's and both are refused the same way. The + frontend therefore drops its reference to a video on a 403 as well as a 404 -- a workflow's + video field clears itself on one. Pinned here because that behaviour reads as over-broad + without this route's answer to point at. + """ + mock_invoker.services.video_records.get_user_id.return_value = None + mock_invoker.services.board_video_records.get_board_for_video.return_value = None + + response = client.get( + "/api/v1/videos/i/gone.mp4", + headers={"Authorization": f"Bearer {user1_token}"}, + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_unreadable_board_does_not_read_as_unavailable_for_videos( + client: TestClient, mock_invoker: Invoker, user1_token: str +): + """A storage error must not reach the client wearing the deleted video's answer. + + ``_assert_video_read_access`` used to catch every exception from the board lookup and fall + through to the same 403 a deleted video gets. Since the clients read that 403 as "gone, drop + your reference", a locked database would have taken every workflow field pointing at a + shared board's videos down with it. + """ + import sqlite3 + + mock_invoker.services.video_records.get_user_id.return_value = "someone-else" + mock_invoker.services.board_video_records.get_board_for_video.return_value = "board-1" + mock_invoker.services.board_records.get = MagicMock(side_effect=sqlite3.OperationalError("database is locked")) + + # The storage error leaves the route uncaught, which is a 500 in production; the test client + # re-raises unhandled server exceptions instead of rendering them. Either way the one thing + # that must not happen is a 403 -- the answer the clients act on destructively. + with pytest.raises(sqlite3.OperationalError): + client.get("/api/v1/videos/i/shared.mp4", headers={"Authorization": f"Bearer {user1_token}"}) + + +def test_vanished_board_still_reads_as_an_ordinary_refusal_for_videos( + client: TestClient, mock_invoker: Invoker, user1_token: str +): + """The narrowed catch stays exactly that narrow, in both directions.""" + from invokeai.app.services.board_records.board_records_common import BoardRecordNotFoundException + + mock_invoker.services.video_records.get_user_id.return_value = "someone-else" + mock_invoker.services.board_video_records.get_board_for_video.return_value = "board-1" + mock_invoker.services.board_records.get = MagicMock(side_effect=BoardRecordNotFoundException) + + response = client.get( + "/api/v1/videos/i/board-gone.mp4", + headers={"Authorization": f"Bearer {user1_token}"}, + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_video_batch_rejects_too_many_or_overlong_names() -> None: with pytest.raises(ValidationError): VideoNamesBatch(video_names=[f"{index}.mp4" for index in range(1001)]) From 0dd3b6d319ae605204cb52a976251d9aca069e87 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Mon, 24 Aug 2026 19:38:35 -0400 Subject: [PATCH 32/34] fix(api,ui): answer gone and denied differently, instead of guessing at the client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clearing a reference on a 403 was wrong: revoking access to a shared board refuses every image on it and every one of them still exists, so a board flipped to Private would clear the workflow fields pointing at its images, and flipping it back would not bring them back. Requiring a 404 was also wrong, for the reason that produced the 403 rule — the ownership decision rests on a `user_id` that is gone with the row, so a deleted image reached the same refusal as a foreign one and no non-admin ever saw a 404 for one. Neither answer was the client's to guess, so the server draws the line. On the refusal path only, both read helpers ask whether the record is actually there: absent answers 404, present answers 403. The clients go back to treating 404 alone as gone. The cost is that a caller can now tell an absent image from one they may not read, which admins could always do, and image names are generated UUIDs. That makes `video_records.get` load-bearing, so it stops translating storage errors into not-found — otherwise a locked database would present as a deleted video and clear the user's fields. It also un-breaks the staged-delete recovery, which read that same exception as "the delete committed" and purged the staged files on a database it merely could not read. An uncertain delete now refetches the names it could not confirm. Their references are left in place, since pruning on a guess would discard work over a request that merely failed, and asking is what settles it: gone answers 404 and the components holding it let go, survived answers with its DTO. Partial by construction — canvas layers hold names with no DTO query behind them, and only handleDeletions prunes those (#9533). --- invokeai/app/api/routers/_access.py | 35 ++++++++++++-- invokeai/app/api/routers/videos.py | 20 +++++--- .../video_records/video_records_sqlite.py | 24 +++++----- .../components/RefImage/RefImageImage.tsx | 14 +++--- .../RegionalGuidanceRefImageImage.tsx | 12 ++--- .../inputs/ImageFieldInputComponent.tsx | 12 ++--- .../inputs/VideoFieldInputComponent.tsx | 10 ++-- .../fields/inputs/videoFieldErrors.test.ts | 19 ++++---- .../fields/inputs/videoFieldErrors.ts | 31 +++++-------- .../src/services/api/endpoints/images.test.ts | 9 ++++ .../web/src/services/api/endpoints/images.ts | 13 ++++++ .../src/services/api/util/imageErrors.test.ts | 27 +++++------ .../web/src/services/api/util/imageErrors.ts | 43 ++++++++--------- .../routers/test_multiuser_authorization.py | 46 +++++++++++++++---- tests/app/routers/test_videos_multiuser.py | 45 ++++++++++++++---- 15 files changed, 231 insertions(+), 129 deletions(-) diff --git a/invokeai/app/api/routers/_access.py b/invokeai/app/api/routers/_access.py index a9575d472b1..73b3fc90faa 100644 --- a/invokeai/app/api/routers/_access.py +++ b/invokeai/app/api/routers/_access.py @@ -12,6 +12,7 @@ BoardRecordNotFoundException, BoardVisibility, ) +from invokeai.app.services.image_records.image_records_common import ImageRecordNotFoundException def assert_image_owner(image_name: str, current_user: CurrentUserOrDefault) -> None: @@ -54,6 +55,33 @@ def assert_image_owner(image_name: str, current_user: CurrentUserOrDefault) -> N raise HTTPException(status_code=403, detail="Not authorized to modify this image") +def _assert_image_record_exists(image_name: str) -> None: + """Turn a refusal into a 404 when the image is positively gone. + + The two refusals mean opposite things to a client holding a reference to the image — a + workflow's image field, a reference image on a canvas layer. Gone is permanent, and the + reference should be dropped. Denied is a permission decision that can be reversed (a board + flipped back to Shared, an owner re-granting access), and dropping the reference over one + destroys work the user cannot get back by restoring the permission. + + Nothing above can tell them apart: the ownership test rests on `images.user_id`, which is + gone with the row, so a deleted image reaches that same 403 as a foreign one. So the + distinction is made here, on the refusal path only — the happy path pays nothing for it. + + A storage error propagates rather than answering either. `image_records.get` deliberately + does not translate sqlite errors into not-found, so an unreadable database cannot present as + a deleted image and take the user's references down with it. + + The cost is that an authenticated caller can now tell an absent image from one they may not + read. Image names are generated UUIDs, so this buys an attacker nothing they could enumerate, + and it is the answer admins have always received. + """ + try: + ApiDependencies.invoker.services.image_records.get(image_name) + except ImageRecordNotFoundException: + raise HTTPException(status_code=404, detail="Image not found") from None + + def assert_image_read_access(image_name: str, current_user: CurrentUserOrDefault) -> None: """Raise 403 if the current user may not view the image. @@ -72,11 +100,7 @@ def assert_image_read_access(image_name: str, current_user: CurrentUserOrDefault board_id = ApiDependencies.invoker.services.board_image_records.get_board_for_image(image_name) if board_id is not None: # See `assert_image_owner` for why this reads the board record and catches only - # not-found. The read side needs it for a second reason: this 403 is also the answer a - # *deleted* image gets, because the decision rests on `images.user_id` and that is gone - # with the row. Clients therefore have to read 403 as "this image is not available to - # me" and drop their reference to it — a workflow's image field clears itself on one. - # An unreadable database must not be able to produce that answer. + # not-found: a lookup that cannot be decided must not present as a permission decision. try: board = ApiDependencies.invoker.services.board_records.get(board_id) except BoardRecordNotFoundException: @@ -85,6 +109,7 @@ def assert_image_read_access(image_name: str, current_user: CurrentUserOrDefault if board.board_visibility in (BoardVisibility.Shared, BoardVisibility.Public): return + _assert_image_record_exists(image_name) raise HTTPException(status_code=403, detail="Not authorized to access this image") diff --git a/invokeai/app/api/routers/videos.py b/invokeai/app/api/routers/videos.py index 41a70e978f0..e80179c5b76 100644 --- a/invokeai/app/api/routers/videos.py +++ b/invokeai/app/api/routers/videos.py @@ -21,7 +21,11 @@ from invokeai.app.services.image_records.image_records_common import ImageCategory, ResourceOrigin from invokeai.app.services.shared.pagination import MAX_PAGE_SIZE, OffsetPaginatedResults from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection -from invokeai.app.services.video_records.video_records_common import VideoNamesResult, VideoRecordChanges +from invokeai.app.services.video_records.video_records_common import ( + VideoNamesResult, + VideoRecordChanges, + VideoRecordNotFoundException, +) from invokeai.app.services.videos.videos_common import ( AddVideosToBoardResult, DeleteVideosResult, @@ -156,11 +160,9 @@ def _assert_video_read_access(video_name: str, current_user: CurrentUserOrDefaul board_id = ApiDependencies.invoker.services.board_video_records.get_board_for_video(video_name) if board_id is not None: - # See `assert_image_read_access`: this 403 is also the answer a *deleted* video gets, - # because the decision rests on a `user_id` that is gone with the row, so clients act on - # it by dropping their reference to the video. Only a board positively known to be gone - # may produce it; a lookup that cannot be decided propagates instead of impersonating a - # permission decision. + # See `assert_image_read_access`: only a board positively known to be gone may fall + # through to a refusal; a lookup that cannot be decided propagates instead of + # impersonating a permission decision. try: board = ApiDependencies.invoker.services.board_records.get(board_id) except BoardRecordNotFoundException: @@ -169,6 +171,12 @@ def _assert_video_read_access(video_name: str, current_user: CurrentUserOrDefaul if board.board_visibility in (BoardVisibility.Shared, BoardVisibility.Public): return + # Gone and denied mean opposite things to a client holding a reference to this video, and + # nothing above can tell them apart. See `_assert_image_record_exists`. + try: + ApiDependencies.invoker.services.video_records.get(video_name) + except VideoRecordNotFoundException: + raise HTTPException(status_code=404, detail="Video not found") from None raise HTTPException(status_code=403, detail="Not authorized to access this video") diff --git a/invokeai/app/services/video_records/video_records_sqlite.py b/invokeai/app/services/video_records/video_records_sqlite.py index 94c943e5321..de49574b901 100644 --- a/invokeai/app/services/video_records/video_records_sqlite.py +++ b/invokeai/app/services/video_records/video_records_sqlite.py @@ -26,18 +26,20 @@ def __init__(self, db: SqliteDatabase) -> None: self._db = db def get(self, video_name: str) -> VideoRecord: + # A sqlite3.Error is deliberately NOT translated into VideoRecordNotFoundException, for + # the same reason as SqliteImageRecordStorage.get: it would make the exception mean "the + # row is absent, OR the database is unreadable". `_assert_video_read_access` now answers + # 404 on a positive not-found, and the clients drop their references to a video on that + # 404 — so a locked database would silently clear the user's workflow fields. with self._db.transaction() as cursor: - try: - cursor.execute( - f"""--sql - SELECT {VIDEO_DTO_COLS} FROM videos - WHERE video_name = ?; - """, - (video_name,), - ) - result = cast(Optional[sqlite3.Row], cursor.fetchone()) - except sqlite3.Error as e: - raise VideoRecordNotFoundException from e + cursor.execute( + f"""--sql + SELECT {VIDEO_DTO_COLS} FROM videos + WHERE video_name = ?; + """, + (video_name,), + ) + result = cast(Optional[sqlite3.Row], cursor.fetchone()) if not result: raise VideoRecordNotFoundException diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageImage.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageImage.tsx index 53bde6d3b0c..57c34aac16f 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageImage.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageImage.tsx @@ -21,7 +21,7 @@ import { useTranslation } from 'react-i18next'; import { PiArrowCounterClockwiseBold, PiCropBold, PiRulerBold } from 'react-icons/pi'; import { useGetImageDTOQuery, useUploadImageMutation } from 'services/api/endpoints/images'; import type { ImageDTO } from 'services/api/types'; -import { isImageUnavailableError } from 'services/api/util/imageErrors'; +import { isImageMissingError } from 'services/api/util/imageErrors'; import { $isConnected } from 'services/events/stores'; type Props = { @@ -57,13 +57,13 @@ export const RefImageImage = memo( }, [onChangeImage]); useEffect(() => { - // Cleared only when the server says the image is not available to this client (404, - // or the 403 a deleted image answers with in multiuser mode). Any other error leaves - // it alone: a 5xx or a dropped connection says nothing about whether the image - // exists, and this reset is silent and has no undo. See `isImageUnavailableError`. + // Cleared only on a confirmed 404. A 403 is a permission decision that can be + // reversed — a board flipped back to Shared — and the image behind it still + // exists; a 5xx or a dropped connection says nothing at all. Dropping the + // reference is silent and has no undo. See `isImageMissingError`. if ( - (isConnected && isImageUnavailableError(croppedImageDTOReq.error)) || - isImageUnavailableError(originalImageDTOReq.error) + (isConnected && isImageMissingError(croppedImageDTOReq.error)) || + isImageMissingError(originalImageDTOReq.error) ) { handleResetControlImage(); } diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceRefImageImage.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceRefImageImage.tsx index c00f1eef4d6..3a304e4093e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceRefImageImage.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceRefImageImage.tsx @@ -17,7 +17,7 @@ import { useTranslation } from 'react-i18next'; import { PiArrowCounterClockwiseBold, PiRulerBold } from 'react-icons/pi'; import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import type { ImageDTO } from 'services/api/types'; -import { isImageUnavailableError } from 'services/api/util/imageErrors'; +import { isImageMissingError } from 'services/api/util/imageErrors'; import { $isConnected } from 'services/events/stores'; type Props = { @@ -39,11 +39,11 @@ export const RegionalGuidanceRefImageImage = memo(({ image, onChangeImage, dndTa }, [onChangeImage]); useEffect(() => { - // Cleared only when the server says the image is not available to this client (404, - // or the 403 a deleted image answers with in multiuser mode). Any other error leaves - // it alone: a 5xx or a dropped connection says nothing about whether the image - // exists, and this reset is silent and has no undo. See `isImageUnavailableError`. - if (isConnected && isImageUnavailableError(error)) { + // Cleared only on a confirmed 404. A 403 is a permission decision that can be + // reversed — a board flipped back to Shared — and the image behind it still + // exists; a 5xx or a dropped connection says nothing at all. Dropping the + // reference is silent and has no undo. See `isImageMissingError`. + if (isConnected && isImageMissingError(error)) { handleResetControlImage(); } }, [handleResetControlImage, error, isConnected]); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/ImageFieldInputComponent.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/ImageFieldInputComponent.tsx index 9383971b6af..89b39aea848 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/ImageFieldInputComponent.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/ImageFieldInputComponent.tsx @@ -14,7 +14,7 @@ import { memo, useCallback, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import type { ImageDTO } from 'services/api/types'; -import { isImageUnavailableError } from 'services/api/util/imageErrors'; +import { isImageMissingError } from 'services/api/util/imageErrors'; import { $isConnected } from 'services/events/stores'; import type { FieldComponentProps } from './types'; @@ -46,11 +46,11 @@ const ImageFieldInputComponent = (props: FieldComponentProps { - // Cleared only when the server says the image is not available to this client (404, - // or the 403 a deleted image answers with in multiuser mode). Any other error leaves - // it alone: a 5xx or a dropped connection says nothing about whether the image - // exists, and this reset is silent and has no undo. See `isImageUnavailableError`. - if (isConnected && isImageUnavailableError(error)) { + // Cleared only on a confirmed 404. A 403 is a permission decision that can be + // reversed — a board flipped back to Shared — and the image behind it still + // exists; a 5xx or a dropped connection says nothing at all. Dropping the + // field is silent and has no undo. See `isImageMissingError`. + if (isConnected && isImageMissingError(error)) { handleReset(); } }, [handleReset, isConnected, error]); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/VideoFieldInputComponent.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/VideoFieldInputComponent.tsx index c321ddbd940..6d83aa649da 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/VideoFieldInputComponent.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/VideoFieldInputComponent.tsx @@ -16,7 +16,7 @@ import { useGetVideoDTOQuery } from 'services/api/endpoints/videos'; import { $isConnected } from 'services/events/stores'; import type { FieldComponentProps } from './types'; -import { isVideoUnavailableError } from './videoFieldErrors'; +import { isVideoMissingError } from './videoFieldErrors'; /** * Counterpart to ImageFieldInputComponent for VideoField inputs. Shows the video's WebP @@ -49,11 +49,11 @@ const VideoFieldInputComponent = (props: FieldComponentProps { - if (isConnected && isVideoUnavailableError(error)) { + if (isConnected && isVideoMissingError(error)) { handleReset(); } }, [handleReset, isConnected, error]); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/videoFieldErrors.test.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/videoFieldErrors.test.ts index d4f95526afc..e51bfa41c11 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/videoFieldErrors.test.ts +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/videoFieldErrors.test.ts @@ -1,20 +1,17 @@ import { describe, expect, it } from 'vitest'; -import { isVideoUnavailableError } from './videoFieldErrors'; +import { isVideoMissingError } from './videoFieldErrors'; -describe('isVideoUnavailableError', () => { +describe('isVideoMissingError', () => { it('is true for a 404 — the video is confirmed gone', () => { - expect(isVideoUnavailableError({ status: 404, data: { detail: 'not found' } })).toBe(true); - }); - - it('is true for a 403 — which is how a deleted video answers in multiuser mode', () => { - // `_assert_video_read_access` decides on `videos.user_id`, and that row is gone, so it - // cannot tell a deleted video from someone else's. Only an admin gets as far as the 404. - // Requiring 404 here would leave every deleted video stuck in the workflows that use it. - expect(isVideoUnavailableError({ status: 403, data: { detail: 'Not authorized' } })).toBe(true); + expect(isVideoMissingError({ status: 404, data: { detail: 'not found' } })).toBe(true); }); it.each([ + // Revoking access to a shared board answers 403 for videos that are all still there. + // Clearing on it would destroy the workflows pointing at them, and restoring the permission + // would not bring them back. + ['denied (403)', { status: 403, data: { detail: 'Not authorized' } }], ['auth (401)', { status: 401, data: {} }], ['server error (500)', { status: 500, data: {} }], ['network failure', { status: 'FETCH_ERROR', error: 'TypeError: Failed to fetch' }], @@ -22,6 +19,6 @@ describe('isVideoUnavailableError', () => { ['parsing failure', { status: 'PARSING_ERROR', originalStatus: 200, data: '', error: 'oops' }], ['no error', undefined], ])('is false for %s — the field value must be preserved', (_label, error) => { - expect(isVideoUnavailableError(error)).toBe(false); + expect(isVideoMissingError(error)).toBe(false); }); }); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/videoFieldErrors.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/videoFieldErrors.ts index d76b3c8b23c..f40d19a410d 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/videoFieldErrors.ts +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/videoFieldErrors.ts @@ -1,23 +1,16 @@ /** - * True when the server has answered that this client cannot have the video: it is gone, or it - * is not theirs to read. Both mean the reference is unusable and the field holding it should - * let it go. + * True only for a confirmed "this video no longer exists" (HTTP 404). * - * Two statuses, because "deleted" does not always arrive as 404. `_assert_video_read_access` - * decides on `videos.user_id`, which disappears with the row, so in a multiuser deployment a - * deleted video is indistinguishable from someone else's and both are refused with 403 — only - * an admin (and so every single-user deployment, whose default user is one) reaches the read - * that 404s. Treating 404 alone as gone would strand a deleted video in every workflow field - * that references it. + * `VideoFieldInputComponent` drops the user's reference when the video behind it is gone. That + * reset is silent and has no undo, so it may only follow an answer that is both definite and + * permanent — which a 403 is not. Access is revoked and restored: flip a board to Private and + * every field referencing its videos would clear; flip it back and the videos are all still + * there, but the workflows that pointed at them are not. * - * Everything else is indeterminate and must NOT discard the user's input: a transient network - * error (`FETCH_ERROR`), an auth failure (401), a timeout or a 5xx says nothing about whether - * the video exists, and the reset is silent and has no undo. - * - * The 403 arm carries a matching obligation on the server, met in `_assert_video_read_access`: - * a storage error must never be laundered into a 403, or an unreadable database would present - * as a permission decision and take the user's references down with it. `isImageUnavailableError` - * is the same predicate for images. + * `_assert_video_read_access` draws that distinction server-side, answering 404 for a video + * positively absent and 403 only for one it is refusing, and `video_records.get` no longer + * translates storage errors into not-found — without which an unreadable database would present + * as a deleted video. `isImageMissingError` is the same predicate for images. */ -export const isVideoUnavailableError = (error: unknown): boolean => - error instanceof Object && 'status' in error && (error.status === 404 || error.status === 403); +export const isVideoMissingError = (error: unknown): boolean => + error instanceof Object && 'status' in error && error.status === 404; diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts index fccce5cec64..ec23c2d0154 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts @@ -1075,6 +1075,15 @@ describe('star invalidation', () => { mutate: (store: ApiStore) => store.dispatch(imagesApi.endpoints.unstarImages.initiate({ image_names: ['a.png'] })), }, + { + // A delete whose outcome is unknown. `handleDeletions` prunes references only for names + // the server confirmed, so nothing else asks about this one; the refetch is what settles + // whether the components holding it should let it go. + label: 'deletion', + body: { deleted_images: [], failed_images: ['a.png'], affected_boards: [] }, + mutate: (store: ApiStore) => + store.dispatch(imagesApi.endpoints.deleteImages.initiate({ image_names: ['a.png'] })), + }, ])('refetches an image whose $label the server could not confirm', async ({ body, mutate }) => { // `ImageService.update` writes the record and then reads the DTO back to return it. A // failure in that read reports the name in `failed_images` with the row already starred, so diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index d74afbba535..058366e3e0b 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -316,6 +316,19 @@ const getDeleteImagesTags = (result: components['schemas']['DeleteImagesResult'] // We ignore the deleted images when getting tags to invalidate. If we did not, we will invalidate the queries // that fetch image DTOs, metadata, and workflows. But we have just deleted those images! Invalidating the tags // will force those queries to re-fetch, and the requests will of course 404. + // + // The *failed* names are the opposite case and are refetched deliberately. Their outcome is + // unknown — a chunk that timed out or 5xx'd may well have committed — so `handleDeletions` + // leaves every reference to them in place, since pruning on a guess would discard work over a + // request that merely failed. Asking is what settles it: a name that is really gone answers + // 404 and every component holding it drops it, and one that survived answers with its DTO and + // keeps it. Names folded in client-side for chunks that never went out ride along and simply + // confirm the cache. + // + // Partial by construction: it repairs what is mounted and subscribed. Canvas raster and + // control layers hold image names without a DTO query behind them, and only `handleDeletions` + // prunes those — which needs a definitive per-name outcome from the server (#9533). + ...getTagsToInvalidateForImageMutation(result.failed_images), ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards), 'ImageCollectionCounts', { type: 'ImageCollection', id: LIST_TAG }, diff --git a/invokeai/frontend/web/src/services/api/util/imageErrors.test.ts b/invokeai/frontend/web/src/services/api/util/imageErrors.test.ts index 751296efc4d..5765308f7c4 100644 --- a/invokeai/frontend/web/src/services/api/util/imageErrors.test.ts +++ b/invokeai/frontend/web/src/services/api/util/imageErrors.test.ts @@ -1,30 +1,27 @@ import { describe, expect, it } from 'vitest'; -import { isImageUnavailableError } from './imageErrors'; +import { isImageMissingError } from './imageErrors'; -describe('isImageUnavailableError', () => { +describe('isImageMissingError', () => { it('is true for a 404 — the image is confirmed gone', () => { - expect(isImageUnavailableError({ status: 404, data: { detail: 'not found' } })).toBe(true); - }); - - it('is true for a 403 — which is how a deleted image answers in multiuser mode', () => { - // `assert_image_read_access` decides on `images.user_id`, and that row is gone, so it - // cannot tell a deleted image from someone else's. Only an admin gets as far as the 404. - // Requiring 404 here would leave every deleted image stuck in the workflows that use it. - expect(isImageUnavailableError({ status: 403, data: { detail: 'Not authorized' } })).toBe(true); + expect(isImageMissingError({ status: 404, data: { detail: 'not found' } })).toBe(true); }); it.each([ - // The one the star/unstar invalidation makes reachable: a name is in `failed_images` - // because a storage failure interrupted its write, and the refetch that the invalidation - // triggers hits the same unwell store. - ['server error (500)', { status: 500, data: {} }], + // The one that matters most: revoking access to a shared board answers 403 for images that + // are all still there. Clearing on it would destroy the workflows pointing at them, and + // restoring the permission would not bring them back. The server answers 404 when an image + // is actually gone, so this arm costs nothing. + ['denied (403)', { status: 403, data: { detail: 'Not authorized' } }], ['unauthorized (401)', { status: 401, data: {} }], + // A name lands in `failed_images` because a storage failure interrupted its write, and the + // refetch that the star/unstar invalidation triggers hits the same unwell store. + ['server error (500)', { status: 500, data: {} }], ['network failure', { status: 'FETCH_ERROR', error: 'TypeError: Failed to fetch' }], ['timeout', { status: 'TIMEOUT_ERROR', error: 'AbortError' }], ['parsing failure', { status: 'PARSING_ERROR', originalStatus: 200, data: '', error: 'oops' }], ['no error', undefined], ])('is false for %s — the reference must be preserved', (_label, error) => { - expect(isImageUnavailableError(error)).toBe(false); + expect(isImageMissingError(error)).toBe(false); }); }); diff --git a/invokeai/frontend/web/src/services/api/util/imageErrors.ts b/invokeai/frontend/web/src/services/api/util/imageErrors.ts index ad3e3d40bad..761cf5e7ab2 100644 --- a/invokeai/frontend/web/src/services/api/util/imageErrors.ts +++ b/invokeai/frontend/web/src/services/api/util/imageErrors.ts @@ -1,26 +1,27 @@ /** - * True when the server has answered that this client cannot have the image: it is gone, or it - * is not theirs to read. Both mean the reference is unusable and the component holding it - * should let it go. + * True only for a confirmed "this image no longer exists" (HTTP 404). * - * Two statuses, because "deleted" does not always arrive as 404. `assert_image_read_access` - * decides on `images.user_id`, which disappears with the row, so in a multiuser deployment a - * deleted image is indistinguishable from someone else's and both are refused with 403 — only - * an admin (and so every single-user deployment, whose default user is one) reaches the read - * that 404s. Treating 404 alone as gone would strand a deleted image in every workflow field - * that references it. + * Several components drop the user's reference when the image behind it is gone — a node's + * image field, a global reference image, a regional guidance reference image. That reset is + * silent and has no undo, so it may only follow an answer that is both definite and permanent. * - * Everything else is indeterminate and must NOT discard the user's input. A transient network - * failure (`FETCH_ERROR`), a timeout, a parse failure or a 5xx says nothing about whether the - * image exists, and this reset is silent and has no undo. That distinction became load-bearing - * when the star/unstar mutations began invalidating the DTOs of names the server reported in - * `failed_images`: a name is reported there precisely because a storage failure interrupted its - * write, so the refetch the invalidation triggers is running against a store that is already - * unwell and is likelier than usual to answer 500. + * A 403 is neither, which is why it is excluded even though it is the answer a deleted image + * *used* to produce for a non-admin. Access is revoked and restored: flip a board to Private + * and every reference to its images would clear; flip it back and the images are all still + * there, but the workflows that pointed at them are not. The server draws the distinction + * instead — `assert_image_read_access` answers 404 for an image positively absent and 403 only + * for one it is refusing — and that is what makes 404 alone the right test here. * - * The 403 arm carries a matching obligation on the server, met in `assert_image_read_access`: - * a storage error must never be laundered into a 403, or an unreadable database would present - * as a permission decision and take the user's references down with it. + * Everything else is indeterminate and must NOT discard the user's input: a transient network + * failure (`FETCH_ERROR`), a timeout, a parse failure, a 401 or a 5xx says nothing about + * whether the image exists. That became load-bearing when the star/unstar mutations began + * invalidating the DTOs of names reported in `failed_images`: a name is reported there precisely + * because a storage failure interrupted its write, so the refetch the invalidation triggers is + * running against a store that is already unwell and is likelier than usual to answer 500. + * + * The server side carries the matching obligation, met in `image_records.get`: a storage error + * must never be translated into not-found, or an unreadable database would present as a + * deleted image and take the user's references down with it. */ -export const isImageUnavailableError = (error: unknown): boolean => - error instanceof Object && 'status' in error && (error.status === 404 || error.status === 403); +export const isImageMissingError = (error: unknown): boolean => + error instanceof Object && 'status' in error && error.status === 404; diff --git a/tests/app/routers/test_multiuser_authorization.py b/tests/app/routers/test_multiuser_authorization.py index b4c60b3f298..87f4da346ad 100644 --- a/tests/app/routers/test_multiuser_authorization.py +++ b/tests/app/routers/test_multiuser_authorization.py @@ -864,16 +864,16 @@ def test_get_image_dto_requires_auth(self, enable_multiuser: Any, client: TestCl r = client.get("/api/v1/images/i/some-image") assert r.status_code == status.HTTP_401_UNAUTHORIZED - def test_deleted_image_reads_as_unavailable_rather_than_undecidable( + def test_deleted_image_reads_as_gone_rather_than_denied( self, client: TestClient, mock_invoker: Invoker, user1_token: str ): - """A deleted image answers 403, and the clients depend on being able to trust it. + """A deleted image answers 404 even to a non-admin, and the clients depend on it. - The read decision rests on `images.user_id`, which is gone with the row, so a - non-admin cannot be told a deleted image from someone else's and both are refused - the same way. The frontend therefore drops its reference to an image on a 403 as - well as a 404 -- a workflow's image field clears itself on one. Pinned here because - that behaviour reads as over-broad without this route's answer to point at. + The ownership decision rests on `images.user_id`, which is gone with the row, so + nothing above the refusal can tell a deleted image from a foreign one -- both used to + come back 403. The two mean opposite things to a client holding a reference: gone is + permanent and the reference should go with it, denied is reversible and it must not. + Only the refusal path pays for the distinction. """ user1 = mock_invoker.services.users.get_by_email("user1@test.com") assert user1 is not None @@ -882,6 +882,32 @@ def test_deleted_image_reads_as_unavailable_rather_than_undecidable( r = client.get("/api/v1/images/i/user1-doomed", headers=_auth(user1_token)) + assert r.status_code == status.HTTP_404_NOT_FOUND + + def test_revoking_access_to_a_live_image_stays_a_denial( + self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str + ): + """The other half, and the one with teeth: a reversible refusal must not read as gone. + + A shared board flipped back to Private refuses every image on it, and every one of + them still exists. The clients drop a workflow field or a reference image on a 404, so + answering one here would destroy work that restoring the permission could not bring + back. + """ + user1 = mock_invoker.services.users.get_by_email("user1@test.com") + assert user1 is not None + board_id = _create_board(client, user1_token, "User1 Formerly Shared Board") + _set_board_visibility(client, user1_token, board_id, "shared") + _save_image(mock_invoker, "user1-still-here", user1.user_id) + mock_invoker.services.board_image_records.add_image_to_board(board_id, "user1-still-here") + assert client.get("/api/v1/images/i/user1-still-here", headers=_auth(user2_token)).status_code == ( + status.HTTP_200_OK + ) + + _set_board_visibility(client, user1_token, board_id, "private") + + r = client.get("/api/v1/images/i/user1-still-here", headers=_auth(user2_token)) + assert r.status_code == status.HTTP_403_FORBIDDEN def test_unreadable_board_does_not_read_as_unavailable( @@ -921,7 +947,11 @@ def test_unreadable_board_does_not_read_as_unavailable( def test_vanished_board_still_reads_as_an_ordinary_refusal( self, client: TestClient, mock_invoker: Invoker, monkeypatch: Any, user1_token: str, user2_token: str ): - """The narrowed catch stays exactly that narrow, in both directions.""" + """The narrowed catch stays exactly that narrow, in both directions. + + A dangling board_image row refuses the read, but the image itself is still there, so + the refusal is a 403 and not the 404 that would take the caller's reference with it. + """ from invokeai.app.services.board_records.board_records_common import BoardRecordNotFoundException user1 = mock_invoker.services.users.get_by_email("user1@test.com") diff --git a/tests/app/routers/test_videos_multiuser.py b/tests/app/routers/test_videos_multiuser.py index bfe0444e14e..e582a9dbfe3 100644 --- a/tests/app/routers/test_videos_multiuser.py +++ b/tests/app/routers/test_videos_multiuser.py @@ -257,25 +257,49 @@ def test_delete_videos_from_list_dedupes_repeated_names(client: TestClient, mock assert sorted(delete_calls) == ["dup.mp4", "other.mp4"] -def test_deleted_video_reads_as_unavailable_rather_than_undecidable( - client: TestClient, mock_invoker: Invoker, user1_token: str -): - """A deleted video answers 403, and the clients depend on being able to trust it. +def test_deleted_video_reads_as_gone_rather_than_denied(client: TestClient, mock_invoker: Invoker, user1_token: str): + """A deleted video answers 404 even to a non-admin, and the clients depend on it. - The read decision rests on ``videos.user_id``, which is gone with the row, so a non-admin - cannot be told a deleted video from someone else's and both are refused the same way. The - frontend therefore drops its reference to a video on a 403 as well as a 404 -- a workflow's - video field clears itself on one. Pinned here because that behaviour reads as over-broad - without this route's answer to point at. + The ownership decision rests on ``videos.user_id``, which is gone with the row, so nothing + above the refusal can tell a deleted video from a foreign one -- both used to come back 403. + A workflow's video field drops its reference on a 404, so the two answers have to differ. """ + from invokeai.app.services.video_records.video_records_common import VideoRecordNotFoundException + mock_invoker.services.video_records.get_user_id.return_value = None mock_invoker.services.board_video_records.get_board_for_video.return_value = None + mock_invoker.services.video_records.get = MagicMock(side_effect=VideoRecordNotFoundException) response = client.get( "/api/v1/videos/i/gone.mp4", headers={"Authorization": f"Bearer {user1_token}"}, ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + +def test_revoking_access_to_a_live_video_stays_a_denial(client: TestClient, mock_invoker: Invoker, user1_token: str): + """A reversible refusal must not read as gone. + + A shared board flipped back to Private refuses every video on it, and every one of them + still exists. Answering 404 would clear the workflow fields pointing at them, and restoring + the permission would not bring those back. + """ + from invokeai.app.services.board_records.board_records_common import BoardVisibility + + mock_invoker.services.video_records.get_user_id.return_value = "someone-else" + mock_invoker.services.board_video_records.get_board_for_video.return_value = "board-1" + private_board = MagicMock() + private_board.board_visibility = BoardVisibility.Private + mock_invoker.services.board_records.get = MagicMock(return_value=private_board) + # The video itself is untouched -- `get` returning normally is what says so. + mock_invoker.services.video_records.get = MagicMock(return_value=MagicMock()) + + response = client.get( + "/api/v1/videos/i/still-here.mp4", + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN @@ -311,6 +335,9 @@ def test_vanished_board_still_reads_as_an_ordinary_refusal_for_videos( mock_invoker.services.video_records.get_user_id.return_value = "someone-else" mock_invoker.services.board_video_records.get_board_for_video.return_value = "board-1" mock_invoker.services.board_records.get = MagicMock(side_effect=BoardRecordNotFoundException) + # The video itself is still there, so the refusal is a denial and not the 404 that would + # take the caller's reference with it. + mock_invoker.services.video_records.get = MagicMock(return_value=MagicMock()) response = client.get( "/api/v1/videos/i/board-gone.mp4", From b03de591c94c9cc27c7b0989f115ae1e7dba3d7f Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Mon, 24 Aug 2026 19:49:53 -0400 Subject: [PATCH 33/34] fix(api): stop any failure from wearing the deleted-image answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both DTO routes ended `except Exception: raise HTTPException(404)`, so a board lookup against an unreadable database, or a URL service failure, answered the same 404 as a missing row. That was survivable while a 404 only meant a stale cache entry. It is not survivable now that the clients drop the user's reference on one: a locked database would clear live images out of the workflows using them. Only a genuinely missing record answers 404. Narrowed on these two routes alone, because these are the 404s that are acted on destructively — the media, metadata and workflow routes keep theirs. --- invokeai/app/api/routers/images.py | 8 +++++- invokeai/app/api/routers/videos.py | 4 ++- .../routers/test_multiuser_authorization.py | 26 +++++++++++++++++++ tests/app/routers/test_videos_multiuser.py | 18 +++++++++++++ 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/invokeai/app/api/routers/images.py b/invokeai/app/api/routers/images.py index 2a0a0d78f34..cd7e42f1a26 100644 --- a/invokeai/app/api/routers/images.py +++ b/invokeai/app/api/routers/images.py @@ -317,7 +317,13 @@ def get_image_dto( try: return ApiDependencies.invoker.services.images.get_dto(image_name) - except Exception: + except ImageRecordNotFoundException: + # Only a genuinely missing record answers 404. This route is what a workflow's image + # field and a canvas reference image read, and they drop the user's reference when it + # 404s, so nothing else may wear that answer: a board lookup against an unreadable + # database, or a URL service failure, would otherwise clear a live image out of the + # workflows using it. Narrowed here rather than everywhere, because this is the 404 + # that is acted on destructively — the media, metadata and workflow routes keep theirs. raise HTTPException(status_code=404) diff --git a/invokeai/app/api/routers/videos.py b/invokeai/app/api/routers/videos.py index e80179c5b76..c05b59bfb09 100644 --- a/invokeai/app/api/routers/videos.py +++ b/invokeai/app/api/routers/videos.py @@ -494,7 +494,9 @@ def get_video_dto( _assert_video_read_access(video_name, current_user) try: return ApiDependencies.invoker.services.videos.get_dto(video_name) - except Exception: + except VideoRecordNotFoundException: + # See get_image_dto: this is the 404 a workflow's video field drops its reference on, + # so only a genuinely missing record may produce it. raise HTTPException(status_code=404) diff --git a/tests/app/routers/test_multiuser_authorization.py b/tests/app/routers/test_multiuser_authorization.py index 87f4da346ad..adf422f9291 100644 --- a/tests/app/routers/test_multiuser_authorization.py +++ b/tests/app/routers/test_multiuser_authorization.py @@ -884,6 +884,32 @@ def test_deleted_image_reads_as_gone_rather_than_denied( assert r.status_code == status.HTTP_404_NOT_FOUND + def test_unreadable_storage_does_not_read_as_a_deleted_image( + self, client: TestClient, mock_invoker: Invoker, monkeypatch: Any, user1_token: str + ): + """The DTO route's 404 is the one clients destroy references on, so only absence earns it. + + The route ended `except Exception: raise HTTPException(404)`, so any failure inside + `get_dto` -- the board lookup, the URL service, not just a missing row -- answered the + same 404 that tells a workflow field its image is gone. The caller here owns the image + and it is still there; the board lookup is what breaks. + """ + import sqlite3 + + user1 = mock_invoker.services.users.get_by_email("user1@test.com") + assert user1 is not None + _save_image(mock_invoker, "user1-unreadable", user1.user_id) + monkeypatch.setattr( + mock_invoker.services.board_image_records, + "get_board_for_image", + MagicMock(side_effect=sqlite3.OperationalError("database is locked")), + ) + + # Uncaught in the route, so a 500 in production; the test client re-raises instead of + # rendering it. Either way it must not be the 404 that clears the user's reference. + with pytest.raises(sqlite3.OperationalError): + client.get("/api/v1/images/i/user1-unreadable", headers=_auth(user1_token)) + def test_revoking_access_to_a_live_image_stays_a_denial( self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str ): diff --git a/tests/app/routers/test_videos_multiuser.py b/tests/app/routers/test_videos_multiuser.py index e582a9dbfe3..b174e1e787d 100644 --- a/tests/app/routers/test_videos_multiuser.py +++ b/tests/app/routers/test_videos_multiuser.py @@ -278,6 +278,24 @@ def test_deleted_video_reads_as_gone_rather_than_denied(client: TestClient, mock assert response.status_code == status.HTTP_404_NOT_FOUND +def test_unreadable_storage_does_not_read_as_a_deleted_video( + client: TestClient, mock_invoker: Invoker, admin_token: str +): + """The DTO route's 404 is the one clients destroy references on, so only absence earns it. + + The route ended ``except Exception: raise HTTPException(404)``, so any failure inside + ``get_dto`` answered the same 404 that tells a workflow field its video is gone. + """ + import sqlite3 + + mock_invoker.services.videos.get_dto = MagicMock(side_effect=sqlite3.OperationalError("database is locked")) + + # Uncaught in the route, so a 500 in production; the test client re-raises instead of + # rendering it. Either way it must not be the 404 that clears the user's reference. + with pytest.raises(sqlite3.OperationalError): + client.get("/api/v1/videos/i/unreadable.mp4", headers={"Authorization": f"Bearer {admin_token}"}) + + def test_revoking_access_to_a_live_video_stays_a_denial(client: TestClient, mock_invoker: Invoker, user1_token: str): """A reversible refusal must not read as gone. From 75d8c8781e7713e1484e28649b0ef96a077619dd Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Mon, 24 Aug 2026 20:13:09 -0400 Subject: [PATCH 34/34] fix(api,ui): close what the self-review found around the gone/denied split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four things. The existence probe read the whole record, so a row this version cannot deserialize — an enum value written by a newer one — failed exactly as absence does, and would have reported a live image gone. It is a bare row probe now, which also drops it to one point SELECT per refused name. `video_records.get` not translating storage errors had no test at all, and after the DTO routes were narrowed it is the only thing standing between an unreadable database and a 404 that clears the user's fields. Pinned from both ends: the store propagates rather than reporting the row missing, and the staged-delete recovery keeps the staged files instead of purging them when it cannot read the record. Reconciling an uncertain delete was inert for the deletes people actually perform. Anything up to the batch cap is a single chunk, and a single chunk that fails reports nothing back, so the endpoint's invalidation never runs on a result — the only invalidation is the one the queryFn dispatches for the lost chunk, and it described those names as committed, which is exactly the case the delete tag set skips DTOs for. It now describes them as unconfirmed too, which is what they are, so the refetch that settles them actually happens. Both arms of the reference-image reset now wait for the connection; the original's used to clear regardless of it. --- invokeai/app/api/routers/_access.py | 15 +++--- invokeai/app/api/routers/videos.py | 6 +-- .../image_records/image_records_base.py | 11 ++++ .../image_records/image_records_sqlite.py | 11 ++++ .../video_records/video_records_base.py | 11 ++++ .../video_records/video_records_sqlite.py | 11 ++++ .../components/RefImage/RefImageImage.tsx | 7 ++- .../src/services/api/endpoints/images.test.ts | 33 ++++++++++++ .../web/src/services/api/endpoints/images.ts | 11 +++- tests/app/routers/test_videos_multiuser.py | 10 ++-- .../video_files/test_video_files_disk.py | 22 ++++++++ .../test_video_records_sqlite.py | 51 +++++++++++++++++++ 12 files changed, 178 insertions(+), 21 deletions(-) diff --git a/invokeai/app/api/routers/_access.py b/invokeai/app/api/routers/_access.py index 73b3fc90faa..b039e3bc2d2 100644 --- a/invokeai/app/api/routers/_access.py +++ b/invokeai/app/api/routers/_access.py @@ -12,7 +12,6 @@ BoardRecordNotFoundException, BoardVisibility, ) -from invokeai.app.services.image_records.image_records_common import ImageRecordNotFoundException def assert_image_owner(image_name: str, current_user: CurrentUserOrDefault) -> None: @@ -68,18 +67,18 @@ def _assert_image_record_exists(image_name: str) -> None: gone with the row, so a deleted image reaches that same 403 as a foreign one. So the distinction is made here, on the refusal path only — the happy path pays nothing for it. - A storage error propagates rather than answering either. `image_records.get` deliberately - does not translate sqlite errors into not-found, so an unreadable database cannot present as - a deleted image and take the user's references down with it. + A storage error propagates rather than answering either, so an unreadable database cannot + present as a deleted image and take the user's references down with it. `exists` is a bare + row probe rather than `get` for the same reason from the other side: `get` deserializes, so + a row written by a newer version — an enum value this one does not know — would fail exactly + as absence does, and a live image would be reported gone. The cost is that an authenticated caller can now tell an absent image from one they may not read. Image names are generated UUIDs, so this buys an attacker nothing they could enumerate, and it is the answer admins have always received. """ - try: - ApiDependencies.invoker.services.image_records.get(image_name) - except ImageRecordNotFoundException: - raise HTTPException(status_code=404, detail="Image not found") from None + if not ApiDependencies.invoker.services.image_records.exists(image_name): + raise HTTPException(status_code=404, detail="Image not found") def assert_image_read_access(image_name: str, current_user: CurrentUserOrDefault) -> None: diff --git a/invokeai/app/api/routers/videos.py b/invokeai/app/api/routers/videos.py index c05b59bfb09..cc8526e6f54 100644 --- a/invokeai/app/api/routers/videos.py +++ b/invokeai/app/api/routers/videos.py @@ -173,10 +173,8 @@ def _assert_video_read_access(video_name: str, current_user: CurrentUserOrDefaul # Gone and denied mean opposite things to a client holding a reference to this video, and # nothing above can tell them apart. See `_assert_image_record_exists`. - try: - ApiDependencies.invoker.services.video_records.get(video_name) - except VideoRecordNotFoundException: - raise HTTPException(status_code=404, detail="Video not found") from None + if not ApiDependencies.invoker.services.video_records.exists(video_name): + raise HTTPException(status_code=404, detail="Video not found") raise HTTPException(status_code=403, detail="Not authorized to access this video") diff --git a/invokeai/app/services/image_records/image_records_base.py b/invokeai/app/services/image_records/image_records_base.py index 8c71dfba9e7..64a530b357a 100644 --- a/invokeai/app/services/image_records/image_records_base.py +++ b/invokeai/app/services/image_records/image_records_base.py @@ -25,6 +25,17 @@ def get(self, image_name: str) -> ImageRecord: """Gets an image record.""" pass + @abstractmethod + def exists(self, image_name: str) -> bool: + """Reports whether the image row is present, without reading it. + + Separate from `get` because the callers that need this are deciding whether something is + gone, and `get` cannot answer that alone: it also deserializes, so a row written by a + newer version -- an enum value this one does not know -- fails the same way a missing row + does. A storage error still propagates, because "could not look" is not "not there". + """ + pass + @abstractmethod def get_metadata(self, image_name: str) -> Optional[MetadataField]: """Gets an image's metadata'.""" diff --git a/invokeai/app/services/image_records/image_records_sqlite.py b/invokeai/app/services/image_records/image_records_sqlite.py index ad5803eeb02..2d68967c282 100644 --- a/invokeai/app/services/image_records/image_records_sqlite.py +++ b/invokeai/app/services/image_records/image_records_sqlite.py @@ -65,6 +65,17 @@ def get_user_id(self, image_name: str) -> Optional[str]: return None return cast(Optional[str], dict(result).get("user_id")) + def exists(self, image_name: str) -> bool: + with self._db.transaction() as cursor: + cursor.execute( + """--sql + SELECT 1 FROM images + WHERE image_name = ?; + """, + (image_name,), + ) + return cursor.fetchone() is not None + def get_metadata(self, image_name: str) -> Optional[MetadataField]: # See get(): a storage error must not masquerade as a missing row. with self._db.transaction() as cursor: diff --git a/invokeai/app/services/video_records/video_records_base.py b/invokeai/app/services/video_records/video_records_base.py index 8492e4c4bf0..a8283718388 100644 --- a/invokeai/app/services/video_records/video_records_base.py +++ b/invokeai/app/services/video_records/video_records_base.py @@ -21,6 +21,17 @@ def get(self, video_name: str) -> VideoRecord: """Gets a video record.""" pass + @abstractmethod + def exists(self, video_name: str) -> bool: + """Reports whether the video row is present, without reading it. + + Separate from `get` because the callers that need this are deciding whether something is + gone, and `get` cannot answer that alone: it also deserializes, so a row written by a + newer version -- an enum value this one does not know -- fails the same way a missing row + does. A storage error still propagates, because "could not look" is not "not there". + """ + pass + @abstractmethod def get_metadata(self, video_name: str) -> Optional[MetadataField]: """Gets a video's metadata.""" diff --git a/invokeai/app/services/video_records/video_records_sqlite.py b/invokeai/app/services/video_records/video_records_sqlite.py index de49574b901..d7ca569be54 100644 --- a/invokeai/app/services/video_records/video_records_sqlite.py +++ b/invokeai/app/services/video_records/video_records_sqlite.py @@ -78,6 +78,17 @@ def get_most_recent_video_for_board(self, board_id: str) -> Optional[VideoRecord return None return deserialize_video_record(dict(result)) + def exists(self, video_name: str) -> bool: + with self._db.transaction() as cursor: + cursor.execute( + """--sql + SELECT 1 FROM videos + WHERE video_name = ?; + """, + (video_name,), + ) + return cursor.fetchone() is not None + def get_metadata(self, video_name: str) -> Optional[MetadataField]: with self._db.transaction() as cursor: try: diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageImage.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageImage.tsx index 57c34aac16f..a6e0633f091 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageImage.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageImage.tsx @@ -61,9 +61,12 @@ export const RefImageImage = memo( // reversed — a board flipped back to Shared — and the image behind it still // exists; a 5xx or a dropped connection says nothing at all. Dropping the // reference is silent and has no undo. See `isImageMissingError`. + // Both arms wait for the connection, where the original's used to clear regardless. The + // two images belong to one reference and there is no reading under which losing the crop + // is more suspect than losing the original. if ( - (isConnected && isImageMissingError(croppedImageDTOReq.error)) || - isImageMissingError(originalImageDTOReq.error) + isConnected && + (isImageMissingError(croppedImageDTOReq.error) || isImageMissingError(originalImageDTOReq.error)) ) { handleResetControlImage(); } diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts index ec23c2d0154..3bb3efd3f21 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.test.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.test.ts @@ -1063,6 +1063,39 @@ describe('star invalidation', () => { type ApiStore = ReturnType; + it('reconciles a single-chunk delete whose outcome was lost in transit', async () => { + // Anything up to the batch cap is one request, so this is what an ordinary delete looks like + // when the response never comes back: the mutation rejects, `invalidatesTags` never runs on + // a result, and `handleDeletions` prunes nothing because nothing was confirmed. The only + // thing that can settle it is the invalidation the queryFn dispatches on its way out, and + // the refetch it triggers — which answers 404 if the delete did commit, and the DTO if it + // did not. + login('user-a'); + const fetched: string[] = []; + vi.stubGlobal( + 'fetch', + vi.fn((request: Request) => { + fetched.push(request.url); + if (request.method === 'POST') { + return Promise.reject(new TypeError('Failed to fetch')); + } + return Promise.resolve( + new Response(JSON.stringify({ image_name: 'a.png', starred: false }), { + headers: { 'content-type': 'application/json' }, + }) + ); + }) + ); + const store = buildStore(); + + await store.dispatch(imagesApi.endpoints.getImageDTO.initiate('a.png')); + expect(fetched.filter((url) => url === dtoUrl)).toHaveLength(1); + + await store.dispatch(imagesApi.endpoints.deleteImages.initiate({ image_names: ['a.png'] })); + + await vi.waitFor(() => expect(fetched.filter((url) => url === dtoUrl)).toHaveLength(2)); + }); + it.each([ { label: 'star', diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index 058366e3e0b..c21e276dc31 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -739,7 +739,16 @@ export const imagesApi = api.injectEndpoints({ queryFn: buildChunkedImageBatchQueryFn( () => ({ url: buildImagesUrl('delete'), method: 'POST' }), getDeleteImagesTags, - (image_names) => ({ deleted_images: image_names, failed_images: [], affected_boards: [] }) + // The only one of these that lists its names twice, because a lost delete is the only + // one whose two readings call for different tags. As committed, the names drive the + // board and collection counts. As unconfirmed — which is the truth, and what + // `failed_images` means here — their DTOs are re-asked, and that is the whole + // reconciliation: a name that is really gone answers 404 and the components holding it + // let go. Reporting them only as deleted would invalidate neither, since + // `getDeleteImagesTags` deliberately skips the DTOs of names it believes are gone. This + // is also the only path a single-chunk delete has — anything up to the batch cap is one + // request, so it never reaches the mid-run merge below. + (image_names) => ({ deleted_images: image_names, failed_images: image_names, affected_boards: [] }) ), onQueryStarted: reportImageBatchOutcome, invalidatesTags: (result) => (result ? getDeleteImagesTags(result) : []), diff --git a/tests/app/routers/test_videos_multiuser.py b/tests/app/routers/test_videos_multiuser.py index b174e1e787d..ced769900d5 100644 --- a/tests/app/routers/test_videos_multiuser.py +++ b/tests/app/routers/test_videos_multiuser.py @@ -264,11 +264,9 @@ def test_deleted_video_reads_as_gone_rather_than_denied(client: TestClient, mock above the refusal can tell a deleted video from a foreign one -- both used to come back 403. A workflow's video field drops its reference on a 404, so the two answers have to differ. """ - from invokeai.app.services.video_records.video_records_common import VideoRecordNotFoundException - mock_invoker.services.video_records.get_user_id.return_value = None mock_invoker.services.board_video_records.get_board_for_video.return_value = None - mock_invoker.services.video_records.get = MagicMock(side_effect=VideoRecordNotFoundException) + mock_invoker.services.video_records.exists = MagicMock(return_value=False) response = client.get( "/api/v1/videos/i/gone.mp4", @@ -310,8 +308,8 @@ def test_revoking_access_to_a_live_video_stays_a_denial(client: TestClient, mock private_board = MagicMock() private_board.board_visibility = BoardVisibility.Private mock_invoker.services.board_records.get = MagicMock(return_value=private_board) - # The video itself is untouched -- `get` returning normally is what says so. - mock_invoker.services.video_records.get = MagicMock(return_value=MagicMock()) + # The video itself is untouched, which is what makes this a denial rather than a 404. + mock_invoker.services.video_records.exists = MagicMock(return_value=True) response = client.get( "/api/v1/videos/i/still-here.mp4", @@ -355,7 +353,7 @@ def test_vanished_board_still_reads_as_an_ordinary_refusal_for_videos( mock_invoker.services.board_records.get = MagicMock(side_effect=BoardRecordNotFoundException) # The video itself is still there, so the refusal is a denial and not the 404 that would # take the caller's reference with it. - mock_invoker.services.video_records.get = MagicMock(return_value=MagicMock()) + mock_invoker.services.video_records.exists = MagicMock(return_value=True) response = client.get( "/api/v1/videos/i/board-gone.mp4", diff --git a/tests/app/services/video_files/test_video_files_disk.py b/tests/app/services/video_files/test_video_files_disk.py index f793746b45e..59c6edcf299 100644 --- a/tests/app/services/video_files/test_video_files_disk.py +++ b/tests/app/services/video_files/test_video_files_disk.py @@ -164,6 +164,28 @@ def test_start_restores_staged_delete_when_record_still_exists(storage: DiskVide assert not list((tmp_path / "videos").glob(".delete_*")) +def test_start_keeps_staged_delete_when_the_record_cannot_be_read(storage: DiskVideoFileStorage, tmp_path: Path): + """A database it merely could not read must not count as proof the delete committed. + + The recovery decides between purging the staged files and restoring them by asking whether + the record is still there, so it needs that read to distinguish "gone" from "could not + look". `SqliteVideoRecordStorage.get` used to translate every sqlite3.Error into + VideoRecordNotFoundException, which made an unreadable database delete the user's video + files outright. Now the staged copy survives for a later attempt. + """ + import sqlite3 + + source = _make_source(tmp_path) + storage.save(source_path=source, video_name=VIDEO_NAME, metadata='{"seed": 1}') + storage.stage_delete(VIDEO_NAME) + invoker = MagicMock() + invoker.services.video_records.get.side_effect = sqlite3.OperationalError("database is locked") + + DiskVideoFileStorage(tmp_path / "videos").start(invoker) + + assert list((tmp_path / "videos").glob(".delete_*")), "the staged files were destroyed" + + def test_start_purges_staged_delete_when_record_is_gone(storage: DiskVideoFileStorage, tmp_path: Path): from invokeai.app.services.video_records.video_records_common import VideoRecordNotFoundException diff --git a/tests/app/services/video_records/test_video_records_sqlite.py b/tests/app/services/video_records/test_video_records_sqlite.py index 70d21be87ec..d44858bfbbe 100644 --- a/tests/app/services/video_records/test_video_records_sqlite.py +++ b/tests/app/services/video_records/test_video_records_sqlite.py @@ -7,6 +7,8 @@ behaviour so the regression cannot reappear. """ +import sqlite3 + import pytest from invokeai.app.services.board_records.board_records_sqlite import SqliteBoardRecordStorage @@ -16,6 +18,7 @@ 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 +from invokeai.app.services.video_records.video_records_common import VideoRecordNotFoundException from invokeai.app.services.video_records.video_records_sqlite import SqliteVideoRecordStorage from invokeai.backend.util.logging import InvokeAILogger from tests.fixtures.sqlite_database import create_mock_sqlite_database @@ -207,3 +210,51 @@ def test_videos_survive_owner_deletion_and_remain_admin_only(self, migrated_db: # ...and no regular user inherits it. other_view = store.get_many(user_id="bystander", is_admin=False) assert "doomed.mp4" not in {v.video_name for v in other_view.items} + + +def test_get_propagates_a_storage_error_instead_of_reporting_the_row_missing(store: SqliteVideoRecordStorage): + """An unreadable database must not be indistinguishable from a deleted video. + + `get` used to translate every sqlite3.Error into VideoRecordNotFoundException, which made + that exception mean "the row is absent, OR the read failed". Two callers act destructively + on it: `_assert_video_read_access` answers 404 for a positive not-found and the clients drop + their reference to the video on one, and the staged-delete recovery reads it as proof the + delete committed and purges the staged files. + """ + _save(store, "video-1.mp4", "user-1") + # A real storage failure rather than a patched one: the SELECT below cannot run at all, the + # same shape a locked or corrupt database presents. The row's absence is not what is being + # reported, and the caller must be able to tell. + with store._db.transaction() as cursor: + cursor.execute("DROP TABLE videos;") + + with pytest.raises(sqlite3.OperationalError): + store.get("video-1.mp4") + + +def test_get_still_reports_a_positively_absent_row_as_missing(store: SqliteVideoRecordStorage): + """The narrowing stays exactly that narrow: absence is still absence.""" + with pytest.raises(VideoRecordNotFoundException): + store.get("never-existed.mp4") + + +def test_exists_reports_a_row_get_cannot_deserialize(store: SqliteVideoRecordStorage): + """Presence, not readability. `get` would raise on an enum value this version does not know + — a row written by a newer one — and the refusal path reads that as absence, which would + report a live video gone.""" + _save(store, "video-1.mp4", "user-1") + with store._db.transaction() as cursor: + cursor.execute("UPDATE videos SET video_category = 'from_the_future' WHERE video_name = ?;", ("video-1.mp4",)) + + with pytest.raises(ValueError): + store.get("video-1.mp4") + assert store.exists("video-1.mp4") is True + + +def test_exists_propagates_a_storage_error(store: SqliteVideoRecordStorage): + """ "Could not look" is not "not there" — the caller answers 404 on a False.""" + with store._db.transaction() as cursor: + cursor.execute("DROP TABLE videos;") + + with pytest.raises(sqlite3.OperationalError): + store.exists("video-1.mp4")