Compare commits

..

7 Commits

Author SHA1 Message Date
agent-company 63ca18e9bf Add S3/MinIO storage backend tests for storage.py
21 test cases covering:
- S3StorageBackend: read, write, exists, path_for with mocked boto3
- Error handling: NoSuchKey exception, generic 404, non-404 re-raise
- Bucket auto-creation on init and graceful handling of creation failure
- Constructor credential/endpoint passthrough
- LocalStorageBackend: round-trip read/write, missing file, empty file
- get_storage_backend() factory: local/s3 selection, case-insensitivity

Closes leeworks-agents/SPARC#1660

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 19:20:06 +00:00
AI-Manager a07a0c7fbe Merge pull request 'Fix remaining dark mode issue in Analysis page prose block' (#1628) from feature/1605-dark-mode into main
Test and Lint / test (pull_request) Has been cancelled
Fix remaining dark mode issue in Analysis page prose block (#1628)
2026-04-20 06:41:59 +00:00
AI-Manager 43fd2c9575 Merge pull request 'Expand JWT auth integration tests to 33 cases' (#1627) from feature/1624-jwt-auth-tests into main
Test and Lint / test (pull_request) Has been cancelled
Expand JWT auth integration tests to 33 cases (#1627)
2026-04-20 06:41:47 +00:00
agent-company d4d43cf9b8 Fix prose-invert to only apply in dark mode on Analysis page
The prose-invert class was applied unconditionally, causing inverted
(light) text in light mode within the AI analysis results section.
Changed to dark:prose-invert so it only activates when dark mode is
enabled.

Note: The broader dark mode feature (issue #1605) is already fully
implemented -- ThemeContext, toggle button, CSS variables, dark:
variants across all pages. This fix addresses the only remaining
unstyled element.

Closes leeworks-agents/SPARC#1605

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 06:08:02 +00:00
agent-company 2f2b6382fa Expand JWT auth integration tests from 17 to 33 cases
Add comprehensive edge-case coverage for issue #1624:

- Admin delete user endpoint (5 tests): successful delete, self-delete
  prevention, nonexistent user 404, non-admin 403, missing token rejection
- Admin role change gaps (2 tests): nonexistent user 404, non-admin 403
- Input validation (3 tests): invalid email 422, short password 422,
  missing fields 422 for both register and login
- Token edge cases (4 tests): malformed token, wrong-secret token,
  deleted user token, deleted user refresh
- Token claim verification (1 test): login tokens contain correct claims

All tests use mocked DB fixtures and require no live database.

Closes leeworks-agents/SPARC#1624

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 06:05:54 +00:00
AI-Manager 1319530f04 Merge pull request 'ci: enable ruff linting and pytest in CI pipeline' (#1568) from feature/1559-1560-enable-ci-linting-and-tests into main
Test and Lint / test (pull_request) Has been cancelled
Merge PR #1568: ci: enable ruff linting and pytest in CI pipeline

Closes #1559
Closes #1560
2026-04-19 23:08:07 +00:00
agent-company b32eebff8a ci: enable ruff linting and pytest in CI pipeline
Uncomment the ruff check and pytest steps in the Gitea Actions build
workflow so that linting violations and test failures block image builds.
Fix all pre-existing ruff violations (E402 import ordering in analyzer.py,
F821 undefined name in api.py, I001 unsorted imports in test files, F401
unused import in test_rate_limit.py).

Closes leeworks-agents/SPARC#1559
Closes leeworks-agents/SPARC#1560

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 20:06:10 +00:00
8 changed files with 505 additions and 29 deletions
+15 -14
View File
@@ -28,10 +28,10 @@ jobs:
run: |
pip3 install -r requirements.txt ruff
# - name: Run ruff linter
# shell: sh
# run: |
# ruff check SPARC/ tests/
- name: Run ruff linter
shell: sh
run: |
ruff check SPARC/ tests/
- name: Install Node.js and check TypeScript types
shell: sh
@@ -47,16 +47,17 @@ jobs:
fi
npx tsc --noEmit
# - name: Run pytest
# shell: sh
# env:
# DATABASE_URL: "sqlite://"
# API_KEY: "test-key"
# OPENROUTER_API_KEY: "test-key"
# JWT_SECRET: "test-secret-for-ci"
# APP_ENV: "development"
# run: |
# python3 -m pytest tests/ -v --tb=short -x
- name: Run pytest
shell: sh
env:
DATABASE_URL: "sqlite://"
API_KEY: "test-key"
OPENROUTER_API_KEY: "test-key"
JWT_SECRET: "test-secret-for-ci"
APP_ENV: "development"
run: |
pip3 install pytest
python3 -m pytest tests/ -v --tb=short -x
build-api:
needs: test
+2 -2
View File
@@ -10,13 +10,13 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Callable
from SPARC import config
logger = logging.getLogger(__name__)
from SPARC.database import DatabaseClient
from SPARC.llm import LLMAnalyzer
from SPARC.serp_api import SERP
from SPARC.types import BatchAnalysisResult, CompanyAnalysisResult, Patent, Patents
logger = logging.getLogger(__name__)
class CompanyAnalyzer:
"""Orchestrates end-to-end company performance analysis via patents."""
+6 -2
View File
@@ -3,9 +3,14 @@
Provides REST API endpoints for analyzing company patent portfolios.
"""
from __future__ import annotations
from contextlib import asynccontextmanager
from datetime import datetime
from typing import Annotated, List
from typing import TYPE_CHECKING, Annotated, List
if TYPE_CHECKING:
from SPARC.database import DatabaseClient
from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware
@@ -653,7 +658,6 @@ async def export_company_pdf(
PDF file download
"""
import io
import textwrap
from reportlab.lib import colors
from reportlab.lib.pagesizes import letter
+1 -1
View File
@@ -159,7 +159,7 @@ export function Analysis() {
</button>
</div>
</div>
<div className="prose prose-invert max-w-none">
<div className="prose dark:prose-invert max-w-none">
<div className="text-text-primary whitespace-pre-wrap leading-relaxed">
{result.analysis}
</div>
+209 -9
View File
@@ -1,13 +1,29 @@
"""Tests for JWT authentication flow: register, login, protected routes, refresh, admin access."""
"""Tests for JWT authentication flow: register, login, protected routes, refresh, admin access.
from datetime import datetime, timezone
Covers all five scenarios required by issue #1624:
1. Registration (POST /auth/register)
2. Login (POST /auth/login)
3. Protected route access (GET /auth/me) -- valid, missing, expired, wrong-type tokens
4. Token refresh (POST /auth/refresh)
5. Admin-only endpoints (GET /admin/users, PATCH role, DELETE user)
All tests use mocked DB fixtures and require no live database.
"""
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch
import jwt as pyjwt
import pytest
from fastapi.testclient import TestClient
from SPARC.api import app
from SPARC.auth import create_access_token, create_refresh_token
from SPARC.auth import (
JWT_ALGORITHM,
JWT_SECRET,
create_access_token,
create_refresh_token,
)
@pytest.fixture
@@ -171,12 +187,6 @@ class TestGetMe:
def test_expired_token_returns_401(self, client, mock_db):
"""An expired token should return 401."""
# Create a token that has already expired
from datetime import timedelta
import jwt as pyjwt
from SPARC.auth import JWT_ALGORITHM, JWT_SECRET
payload = {
"sub": "1",
"email": "user@test.com",
@@ -300,3 +310,193 @@ class TestAdminUsers:
assert response.status_code == 400
assert "own role" in response.json()["detail"].lower()
def test_role_change_nonexistent_user_returns_404(self, client, mock_db):
"""Changing role for a user that does not exist should return 404."""
admin = _make_admin_user()
mock_db.get_user_by_id.return_value = admin
mock_db.update_user_role.return_value = None
response = client.patch(
"/admin/users/999/role",
json={"role": "admin"},
headers=_auth_header(admin),
)
assert response.status_code == 404
assert "not found" in response.json()["detail"].lower()
def test_regular_user_cannot_change_role(self, client, mock_db):
"""Non-admin user should receive 403 when trying to change roles."""
user = _make_regular_user()
mock_db.get_user_by_id.return_value = user
response = client.patch(
"/admin/users/1/role",
json={"role": "admin"},
headers=_auth_header(user),
)
assert response.status_code == 403
class TestAdminDeleteUser:
"""DELETE /admin/users/{user_id}"""
def test_admin_can_delete_user(self, client, mock_db):
"""Admin should be able to delete another user."""
admin = _make_admin_user()
mock_db.get_user_by_id.return_value = admin
mock_db.delete_user.return_value = True
response = client.delete(
"/admin/users/2",
headers=_auth_header(admin),
)
assert response.status_code == 200
assert "deleted" in response.json()["message"].lower()
mock_db.delete_user.assert_called_once_with(2)
def test_admin_cannot_delete_self(self, client, mock_db):
"""Admin should not be able to delete themselves."""
admin = _make_admin_user()
mock_db.get_user_by_id.return_value = admin
response = client.delete(
"/admin/users/1",
headers=_auth_header(admin),
)
assert response.status_code == 400
assert "yourself" in response.json()["detail"].lower()
def test_delete_nonexistent_user_returns_404(self, client, mock_db):
"""Deleting a user that does not exist should return 404."""
admin = _make_admin_user()
mock_db.get_user_by_id.return_value = admin
mock_db.delete_user.return_value = False
response = client.delete(
"/admin/users/999",
headers=_auth_header(admin),
)
assert response.status_code == 404
assert "not found" in response.json()["detail"].lower()
def test_regular_user_cannot_delete_user(self, client, mock_db):
"""Non-admin user should receive 403 when trying to delete users."""
user = _make_regular_user()
mock_db.get_user_by_id.return_value = user
response = client.delete(
"/admin/users/1",
headers=_auth_header(user),
)
assert response.status_code == 403
def test_no_token_cannot_delete_user(self, client):
"""Missing token should be rejected for delete endpoint."""
response = client.delete("/admin/users/1")
assert response.status_code in (401, 403)
class TestEdgeCases:
"""Additional edge-case tests for auth robustness."""
def test_register_invalid_email_returns_422(self, client, mock_db):
"""Registration with an invalid email format should return 422."""
response = client.post(
"/auth/register",
json={"email": "not-an-email", "password": "securepass123"},
)
assert response.status_code == 422
def test_register_short_password_returns_422(self, client, mock_db):
"""Registration with a password shorter than 8 chars should return 422."""
response = client.post(
"/auth/register",
json={"email": "user@test.com", "password": "short"},
)
assert response.status_code == 422
def test_register_missing_fields_returns_422(self, client, mock_db):
"""Registration with missing fields should return 422."""
response = client.post("/auth/register", json={})
assert response.status_code == 422
def test_login_missing_fields_returns_422(self, client, mock_db):
"""Login with missing fields should return 422."""
response = client.post("/auth/login", json={"email": "user@test.com"})
assert response.status_code == 422
def test_malformed_token_returns_401(self, client, mock_db):
"""A completely malformed token string should return 401."""
response = client.get(
"/auth/me",
headers={"Authorization": "Bearer not.a.valid.jwt.token"},
)
assert response.status_code == 401
def test_token_with_wrong_secret_returns_401(self, client, mock_db):
"""A token signed with a different secret should return 401."""
payload = {
"sub": "1",
"email": "user@test.com",
"role": "user",
"exp": datetime.now(timezone.utc) + timedelta(hours=1),
"type": "access",
}
wrong_secret_token = pyjwt.encode(payload, "wrong-secret", algorithm=JWT_ALGORITHM)
response = client.get(
"/auth/me",
headers={"Authorization": f"Bearer {wrong_secret_token}"},
)
assert response.status_code == 401
def test_token_for_deleted_user_returns_401(self, client, mock_db):
"""A valid token for a user no longer in the DB should return 401."""
user = _make_regular_user()
mock_db.get_user_by_id.return_value = None # user was deleted
response = client.get("/auth/me", headers=_auth_header(user))
assert response.status_code == 401
def test_refresh_for_deleted_user_returns_401(self, client, mock_db):
"""Refreshing a token for a deleted user should return 401."""
user = _make_regular_user()
mock_db.get_user_by_id.return_value = None
refresh = create_refresh_token(user["id"], user["email"], user["role"])
response = client.post(
"/auth/refresh", json={"refresh_token": refresh}
)
assert response.status_code == 401
def test_login_returns_decodable_tokens(self, client, mock_db):
"""Tokens returned by login should be decodable and contain expected claims."""
user = _make_regular_user()
mock_db.authenticate_user.return_value = user
response = client.post(
"/auth/login",
json={"email": "user@test.com", "password": "correctpassword"},
)
data = response.json()
access_payload = pyjwt.decode(
data["access_token"], JWT_SECRET, algorithms=[JWT_ALGORITHM]
)
assert access_payload["sub"] == str(user["id"])
assert access_payload["email"] == user["email"]
assert access_payload["type"] == "access"
refresh_payload = pyjwt.decode(
data["refresh_token"], JWT_SECRET, algorithms=[JWT_ALGORITHM]
)
assert refresh_payload["type"] == "refresh"
+2 -1
View File
@@ -1,7 +1,8 @@
"""Tests for rate limiting on auth endpoints."""
from unittest.mock import MagicMock, patch
import pytest
from unittest.mock import Mock, patch, MagicMock
from fastapi.testclient import TestClient
from SPARC.api import app
+7
View File
@@ -14,6 +14,7 @@ class TestJWTSecretStartupCheck:
with patch.dict(os.environ, {"APP_ENV": "production"}):
# Reload config to pick up the new APP_ENV
import importlib
import SPARC.config
importlib.reload(SPARC.config)
@@ -31,6 +32,7 @@ class TestJWTSecretStartupCheck:
"""Starting with default secret and APP_ENV=development must not raise."""
with patch.dict(os.environ, {"APP_ENV": "development"}):
import importlib
import SPARC.config
importlib.reload(SPARC.config)
@@ -46,6 +48,7 @@ class TestJWTSecretStartupCheck:
"""Starting with a custom secret in production must not raise."""
with patch.dict(os.environ, {"APP_ENV": "production"}):
import importlib
import SPARC.config
importlib.reload(SPARC.config)
@@ -65,6 +68,7 @@ class TestJWTSecretStartupCheck:
env.pop("APP_ENV", None)
with patch.dict(os.environ, env, clear=True):
import importlib
import SPARC.config
importlib.reload(SPARC.config)
@@ -84,6 +88,7 @@ class TestCORSConfig:
"""When CORS_ORIGINS is unset, defaults to localhost origins."""
with patch.dict(os.environ, {"CORS_ORIGINS": ""}):
import importlib
import SPARC.config
importlib.reload(SPARC.config)
assert SPARC.config.cors_origins == [
@@ -95,6 +100,7 @@ class TestCORSConfig:
"""Setting CORS_ORIGINS configures allowed origins."""
with patch.dict(os.environ, {"CORS_ORIGINS": "https://sparc.example.com,https://app.example.com"}):
import importlib
import SPARC.config
importlib.reload(SPARC.config)
assert SPARC.config.cors_origins == [
@@ -109,6 +115,7 @@ class TestCORSConfig:
"""A single origin without comma works correctly."""
with patch.dict(os.environ, {"CORS_ORIGINS": "https://sparc.example.com"}):
import importlib
import SPARC.config
importlib.reload(SPARC.config)
assert SPARC.config.cors_origins == ["https://sparc.example.com"]
+263
View File
@@ -0,0 +1,263 @@
"""Tests for S3/MinIO storage backend in storage.py.
Covers issue #1660:
- S3StorageBackend read, write, exists, path_for
- Error handling: NoSuchKey, generic S3 errors, bucket auto-creation
- get_storage_backend() factory function
- LocalStorageBackend (basic sanity checks)
"""
from unittest.mock import MagicMock, patch
import pytest
from SPARC.storage import LocalStorageBackend, S3StorageBackend, get_storage_backend
# ---------- S3StorageBackend ----------
class TestS3StorageBackend:
"""Tests for the S3-compatible storage backend."""
@pytest.fixture
def s3_backend(self):
"""Create an S3StorageBackend with a fully mocked boto3 client."""
with patch.dict("sys.modules", {"boto3": MagicMock()}):
import boto3 as mock_boto
mock_s3 = MagicMock()
mock_boto.client.return_value = mock_s3
mock_s3.head_bucket.return_value = {}
backend = S3StorageBackend(
bucket="test-bucket",
endpoint_url="http://minio:9000",
access_key="minioadmin",
secret_key="minioadmin",
)
# Expose mock for assertions
backend._mock_s3 = mock_s3
yield backend
def test_write_puts_object(self, s3_backend):
"""write() calls put_object with correct bucket, key, and body."""
s3_backend.write("US-12345678-B2.pdf", b"PDF content here")
s3_backend._mock_s3.put_object.assert_called_once_with(
Bucket="test-bucket",
Key="US-12345678-B2.pdf",
Body=b"PDF content here",
ContentType="application/pdf",
)
def test_read_returns_body(self, s3_backend):
"""read() returns the Body content from get_object."""
mock_body = MagicMock()
mock_body.read.return_value = b"PDF data"
s3_backend._mock_s3.get_object.return_value = {"Body": mock_body}
result = s3_backend.read("US-12345678-B2.pdf")
assert result == b"PDF data"
s3_backend._mock_s3.get_object.assert_called_once_with(
Bucket="test-bucket",
Key="US-12345678-B2.pdf",
)
def test_read_nosuchkey_raises_file_not_found(self, s3_backend):
"""read() raises FileNotFoundError when object does not exist."""
# Create a NoSuchKey exception class on the mock
nosuchkey = type("NoSuchKey", (Exception,), {})
s3_backend._mock_s3.exceptions.NoSuchKey = nosuchkey
s3_backend._mock_s3.get_object.side_effect = nosuchkey("not found")
# Reassign s3 to trigger the except branch
s3_backend.s3 = s3_backend._mock_s3
with pytest.raises(FileNotFoundError, match="S3 object not found"):
s3_backend.read("missing.pdf")
def test_read_generic_404_raises_file_not_found(self, s3_backend):
"""read() handles generic 404 errors from S3-compatible APIs."""
nosuchkey = type("NoSuchKey", (Exception,), {})
s3_backend._mock_s3.exceptions.NoSuchKey = nosuchkey
s3_backend.s3 = s3_backend._mock_s3
s3_backend.s3.get_object.side_effect = Exception("An error occurred (404)")
with pytest.raises(FileNotFoundError, match="S3 object not found"):
s3_backend.read("missing.pdf")
def test_read_other_error_re_raises(self, s3_backend):
"""read() re-raises non-404 errors."""
nosuchkey = type("NoSuchKey", (Exception,), {})
s3_backend._mock_s3.exceptions.NoSuchKey = nosuchkey
s3_backend.s3 = s3_backend._mock_s3
s3_backend.s3.get_object.side_effect = Exception("Internal server error")
with pytest.raises(Exception, match="Internal server error"):
s3_backend.read("some-file.pdf")
def test_exists_returns_true_for_existing_object(self, s3_backend):
"""exists() returns True when head_object succeeds with content."""
s3_backend._mock_s3.head_object.return_value = {"ContentLength": 1024}
assert s3_backend.exists("US-12345678-B2.pdf") is True
def test_exists_returns_false_for_missing_object(self, s3_backend):
"""exists() returns False when head_object raises an exception."""
s3_backend._mock_s3.head_object.side_effect = Exception("Not Found")
assert s3_backend.exists("missing.pdf") is False
def test_exists_returns_false_for_zero_length(self, s3_backend):
"""exists() returns False when object has zero content length."""
s3_backend._mock_s3.head_object.return_value = {"ContentLength": 0}
assert s3_backend.exists("empty.pdf") is False
def test_path_for_returns_s3_uri(self, s3_backend):
"""path_for() returns an s3:// URI."""
path = s3_backend.path_for("US-12345678-B2.pdf")
assert path == "s3://test-bucket/US-12345678-B2.pdf"
def test_constructor_creates_bucket_if_missing(self):
"""Constructor creates the bucket if head_bucket fails."""
with patch.dict("sys.modules", {"boto3": MagicMock()}):
import boto3 as mock_boto
mock_s3 = MagicMock()
mock_boto.client.return_value = mock_s3
mock_s3.head_bucket.side_effect = Exception("Bucket not found")
S3StorageBackend(
bucket="new-bucket",
endpoint_url="http://minio:9000",
access_key="admin",
secret_key="admin",
)
mock_s3.create_bucket.assert_called_once_with(Bucket="new-bucket")
def test_constructor_handles_bucket_creation_failure(self):
"""Constructor logs warning but does not crash if bucket creation fails."""
with patch.dict("sys.modules", {"boto3": MagicMock()}):
import boto3 as mock_boto
mock_s3 = MagicMock()
mock_boto.client.return_value = mock_s3
mock_s3.head_bucket.side_effect = Exception("Bucket not found")
mock_s3.create_bucket.side_effect = Exception("Permission denied")
# Should not raise
backend = S3StorageBackend(
bucket="locked-bucket",
endpoint_url="http://minio:9000",
access_key="admin",
secret_key="admin",
)
assert backend.bucket == "locked-bucket"
def test_constructor_passes_endpoint_and_credentials(self):
"""Constructor passes endpoint_url and credentials to boto3.client."""
with patch.dict("sys.modules", {"boto3": MagicMock()}):
import boto3 as mock_boto
mock_s3 = MagicMock()
mock_boto.client.return_value = mock_s3
S3StorageBackend(
bucket="test",
endpoint_url="http://minio:9000",
access_key="mykey",
secret_key="mysecret",
)
mock_boto.client.assert_called_with(
"s3",
endpoint_url="http://minio:9000",
aws_access_key_id="mykey",
aws_secret_access_key="mysecret",
)
# ---------- LocalStorageBackend ----------
class TestLocalStorageBackend:
"""Basic sanity checks for the local filesystem backend."""
def test_write_and_read(self, tmp_path):
"""Write and read round-trip produces identical content."""
backend = LocalStorageBackend(base_dir=str(tmp_path))
backend.write("test.pdf", b"hello world")
result = backend.read("test.pdf")
assert result == b"hello world"
def test_read_missing_file_raises(self, tmp_path):
"""Reading a non-existent file raises FileNotFoundError."""
backend = LocalStorageBackend(base_dir=str(tmp_path))
with pytest.raises(FileNotFoundError):
backend.read("nonexistent.pdf")
def test_exists_true_for_written_file(self, tmp_path):
"""exists() returns True after writing a file."""
backend = LocalStorageBackend(base_dir=str(tmp_path))
backend.write("test.pdf", b"data")
assert backend.exists("test.pdf") is True
def test_exists_false_for_missing_file(self, tmp_path):
"""exists() returns False for non-existent file."""
backend = LocalStorageBackend(base_dir=str(tmp_path))
assert backend.exists("missing.pdf") is False
def test_exists_false_for_empty_file(self, tmp_path):
"""exists() returns False for zero-length file."""
backend = LocalStorageBackend(base_dir=str(tmp_path))
backend.write("empty.pdf", b"")
assert backend.exists("empty.pdf") is False
def test_path_for_returns_full_path(self, tmp_path):
"""path_for() returns the full filesystem path."""
backend = LocalStorageBackend(base_dir=str(tmp_path))
path = backend.path_for("test.pdf")
assert path == str(tmp_path / "test.pdf")
# ---------- get_storage_backend() factory ----------
class TestGetStorageBackend:
"""Tests for the storage backend factory function."""
@patch("SPARC.storage.config")
def test_returns_local_backend_by_default(self, mock_config):
"""Default config returns LocalStorageBackend."""
mock_config.storage_backend = "local"
backend = get_storage_backend()
assert isinstance(backend, LocalStorageBackend)
@patch("SPARC.storage.config")
def test_returns_s3_backend_when_configured(self, mock_config):
"""Setting storage_backend=s3 returns S3StorageBackend."""
mock_config.storage_backend = "s3"
mock_config.s3_bucket = "test-bucket"
mock_config.s3_endpoint_url = "http://minio:9000"
mock_config.s3_access_key = "key"
mock_config.s3_secret_key = "secret"
with patch.dict("sys.modules", {"boto3": MagicMock()}):
backend = get_storage_backend()
assert isinstance(backend, S3StorageBackend)
@patch("SPARC.storage.config")
def test_case_insensitive_backend_selection(self, mock_config):
"""Backend selection is case-insensitive."""
mock_config.storage_backend = "LOCAL"
backend = get_storage_backend()
assert isinstance(backend, LocalStorageBackend)