Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
docker/mysql/data
r/mysql/data

# Byte-compiled / optimized / DLL files
__pycache__/
Expand Down
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ repos:
additional_dependencies:
- fastapi
- pytest
- pydantic>=2.6,<2.7

- repo: https://github.com/astral-sh/ruff-pre-commit
rev: 'v0.6.9'
Expand Down
1,724 changes: 1,724 additions & 0 deletions poetry.lock

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion src/routers/dependencies.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
from typing import Annotated

from fastapi import Depends
from fastapi.security import APIKeyHeader
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

Non-standard Authorization header usage without scheme prefix.

The implementation uses the Authorization header to carry raw API keys without a scheme prefix (e.g., Authorization: <api_key>). This deviates from RFC 7235 and common REST API practices, where the Authorization header should follow the format Authorization: <scheme> <credentials> (e.g., Authorization: Bearer <token> or Authorization: ApiKey <key>).

Recommended alternatives:

  1. Use a custom header (preferred for API keys): X-API-Key or X-Auth-Token
  2. Use a proper scheme: Authorization: Bearer <api_key> or Authorization: ApiKey <api_key>

Using a non-standard format may cause confusion for API consumers and compatibility issues with HTTP clients, proxies, or API gateways that expect standard Authorization header formats.

Also applies to: 11-12


🌐 Web query:

What is the standard format for API key authentication in HTTP Authorization headers according to RFC 7235 and REST API best practices?

💡 Result:

Short answer

  • RFC 7235 defines the Authorization header as: Authorization: (credentials may be a token68 or auth-params). [1]
  • In practice for API keys you should put the key in the Authorization header using a clear auth-scheme token, e.g. Authorization: Bearer (recommended for opaque/JWT tokens per RFC 6750) or Authorization: ApiKey <your_api_key> / Authorization: Api-Key <your_api_key> for a custom scheme. Do not put keys in the URL. [1][2][3][4]

Recommended minimal rules / best practices

  • Use the Authorization header (not query string) and always use HTTPS/TLS. [2][3]
  • Use a well‑known scheme if available (Bearer for OAuth2 tokens); if you define a custom scheme use a token-style credential (no colon/illegal chars) consistent with RFC 7235. [1][2]
  • Make keys short‑lived or rotate them, scope and least‑privilege, log and monitor use, and allow revocation. Avoid storing keys in client-side places that can be leaked. [2][3]

References

  • RFC 7235 — HTTP/1.1: Authentication (Authorization header syntax). [1]
  • RFC 6750 — OAuth 2.0 Bearer Token Usage (Authorization: Bearer , security recommendations). [2]
  • MDN Web Docs — Authorization / WWW-Authenticate header (auth-scheme, token68, auth-param syntax). [3]
  • Common REST practice / libraries showing Authorization: Api-Key / ApiKey usage (example implementations). [4]

🏁 Script executed:

#!/bin/bash
# Examine the dependencies.py file to understand the actual implementation
cat src/routers/dependencies.py

Length of output: 976


🏁 Script executed:

#!/bin/bash
# Also search for APIKeyHeader usage patterns in the codebase
rg -n "APIKeyHeader" -A 5 -B 2

Length of output: 872


🌐 Web query:

FastAPI APIKeyHeader Authorization header scheme format

💡 Result:

Short answer

  • APIKeyHeader just extracts the raw header value (by header name) and returns it as a string — it does NOT parse an "Authorization: Scheme credentials" value. [1]
  • If you need an Authorization header with a scheme (e.g. "Authorization: Bearer ") use HTTPBearer / OAuth2 classes (they split scheme vs credentials) or parse the header yourself with get_authorization_scheme_param. [2][3]

Minimal examples

  1. APIKeyHeader (reads header value directly)
    from fastapi import Depends, FastAPI
    from fastapi.security import APIKeyHeader

app = FastAPI()
api_key_header = APIKeyHeader(name="X-API-Key")

@app.get("/items/")
async def read_items(key: str = Depends(api_key_header)):
return {"key": key}
(returns the header string exactly as sent). [1]

  1. Use HTTPBearer to get scheme + credentials
    from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials

bearer = HTTPBearer()

@app.get("/secure/")
async def secure(creds: HTTPAuthorizationCredentials = Depends(bearer)):
# creds.scheme == "Bearer", creds.credentials == ""
return {"scheme": creds.scheme, "token": creds.credentials}
(HTTPBearer splits "Authorization: Bearer " for you). [2]

  1. Manual parsing with get_authorization_scheme_param
    from fastapi.security.utils import get_authorization_scheme_param
    authorization = request.headers.get("Authorization")
    scheme, param = get_authorization_scheme_param(authorization)
    if scheme.lower() == "bearer":
    token = param

References
[1] FastAPI — APIKeyHeader docs.
[2] FastAPI — HTTP auth / HTTPAuthorizationCredentials (HTTPBearer) docs.
[3] Example usage of get_authorization_scheme_param in FastAPI (parsing Authorization header).


🌐 Web query:

FastAPI APIKeyHeader documentation how to use with Authorization header

💡 Result:

Short answer: use APIKeyHeader with name="Authorization" as a Security dependency (not plain Depends), then parse/strip the "Bearer " prefix yourself. Example:

from fastapi import FastAPI, Security, HTTPException, status
from fastapi.security.api_key import APIKeyHeader

app = FastAPI()
api_key_header = APIKeyHeader(name="Authorization", auto_error=False)

async def get_api_key(authorization: str = Security(api_key_header)):
    if not authorization:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing auth")
    # if using Bearer tokens, strip the prefix:
    if authorization.startswith("Bearer "):
        token = authorization[len("Bearer "):]
    else:
        token = authorization
    # validate token...
    return token

@app.get("/protected")
async def protected(api_key: str = Security(get_api_key)):
    return {"token": api_key}

Notes:

  • Use Security(...) so FastAPI documents the scheme and shows the Authorize button in /docs. [1]
  • APIKeyHeader simply extracts the header value — it won’t remove “Bearer ” for you. Use auto_error=False if you need optional auth or multiple auth methods. [1][2]

Sources: FastAPI security docs and community examples. [1] FastAPI security/APIKeyHeader reference. [2] StackOverflow discussion showing APIKeyHeader with Authorization.


Use HTTPBearer or custom header to enforce RFC 7235 Authorization header format.

The current implementation uses APIKeyHeader(name="Authorization") which accepts raw API key values without enforcing a scheme prefix, violating RFC 7235. RFC 7235 requires Authorization headers to follow the format Authorization: <auth-scheme> <credentials> (e.g., Bearer <token> or ApiKey <key>). FastAPI's APIKeyHeader simply extracts the raw header value without scheme parsing or validation.

Recommended fix:

  1. For Bearer tokens: Use HTTPBearer() dependency (automatically parses scheme and credentials)
  2. For API keys: Use custom header X-API-Key instead of Authorization
  3. If Authorization header required: Manually parse with get_authorization_scheme_param() to extract and validate the scheme
🤖 Prompt for AI Agents
In src/routers/dependencies.py around line 4, the code currently uses
APIKeyHeader(name="Authorization") which accepts raw header values and does not
enforce RFC 7235 scheme parsing; replace it with an HTTPBearer() dependency
(importing HTTPBearer and HTTPAuthorizationCredentials) to automatically parse
scheme + credentials and then validate that the scheme is "bearer"
(case-insensitive) before returning the token. If you intend to use API keys
instead, switch the client header to a custom header name like "X-API-Key" and
continue using APIKeyHeader for that header. Alternatively, if you must keep
Authorization, use get_authorization_scheme_param() to manually split and
validate the scheme and credentials and raise HTTPException(401) on
missing/invalid scheme.

from pydantic import BaseModel
from sqlalchemy import Connection

from database.setup import expdb_database, user_database
from database.users import APIKey, User

api_key_header = APIKeyHeader(name="Authorization", auto_error=False)


def expdb_connection() -> Connection:
engine = expdb_database()
Expand All @@ -23,7 +26,7 @@ def userdb_connection() -> Connection:


def fetch_user(
api_key: APIKey | None = None,
api_key: Annotated[APIKey | None, Depends(api_key_header)] = None,
user_data: Annotated[Connection, Depends(userdb_connection)] = None,
) -> User | None:
return User.fetch(api_key, user_data) if api_key else None
Expand Down
18 changes: 14 additions & 4 deletions src/routers/openml/tasktype.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,20 @@ def get_task_type(
if task_type_input.requirement == "required":
input_["requirement"] = task_type_input.requirement
input_["name"] = task_type_input.name
# api_constraints is for one input only in the test database (TODO: patch db)
if isinstance(task_type_input.api_constraints, str):
constraint = json.loads(task_type_input.api_constraints)
input_["data_type"] = constraint["data_type"]
# Parse api_constraints if present (handles both string and already-parsed JSON)
if task_type_input.api_constraints:
if isinstance(task_type_input.api_constraints, str):
try:
constraint = json.loads(task_type_input.api_constraints)
except json.JSONDecodeError:
# If JSON parsing fails, skip constraint (database may have malformed data)
constraint = None
else:
# Already parsed as dict/list
constraint = task_type_input.api_constraints

if constraint and isinstance(constraint, dict) and "data_type" in constraint:
input_["data_type"] = constraint["data_type"]
input_types.append(input_)
task_type["input"] = input_types
return {"task_type": task_type}
5 changes: 4 additions & 1 deletion src/schemas/datasets/mldcat_ap.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,10 @@ class DataService(JsonLDObject):


class JsonLDGraph(BaseModel):
context: str | dict[str, HttpUrl] = Field(default_factory=dict, serialization_alias="@context")
context: str | dict[str, HttpUrl] = Field(
default_factory=dict,
serialization_alias="@context",
)
graph: list[Distribution | DataService | Dataset | Quality | Feature | Agent | MD5Checksum] = (
Field(default_factory=list, serialization_alias="@graph")
)
Expand Down
17 changes: 11 additions & 6 deletions tests/routers/openml/dataset_tag_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@
ids=["no authentication", "invalid key"],
)
def test_dataset_tag_rejects_unauthorized(key: ApiKey, py_api: TestClient) -> None:
apikey = "" if key is None else f"?api_key={key}"
headers = {} if key is None else {"Authorization": key}
response = py_api.post(
f"/datasets/tag{apikey}",
"/datasets/tag",
json={"data_id": next(iter(constants.PRIVATE_DATASET_ID)), "tag": "test"},
headers=headers,
)
assert response.status_code == HTTPStatus.PRECONDITION_FAILED
assert response.json()["detail"] == {"code": "103", "message": "Authentication failed"}
Expand All @@ -32,8 +33,9 @@ def test_dataset_tag_rejects_unauthorized(key: ApiKey, py_api: TestClient) -> No
def test_dataset_tag(key: ApiKey, expdb_test: Connection, py_api: TestClient) -> None:
dataset_id, tag = next(iter(constants.PRIVATE_DATASET_ID)), "test"
response = py_api.post(
f"/datasets/tag?api_key={key}",
"/datasets/tag",
json={"data_id": dataset_id, "tag": tag},
headers={"Authorization": key},
)
assert response.status_code == HTTPStatus.OK
assert response.json() == {"data_tag": {"id": str(dataset_id), "tag": [tag]}}
Expand All @@ -45,8 +47,9 @@ def test_dataset_tag(key: ApiKey, expdb_test: Connection, py_api: TestClient) ->
def test_dataset_tag_returns_existing_tags(py_api: TestClient) -> None:
dataset_id, tag = 1, "test"
response = py_api.post(
f"/datasets/tag?api_key={ApiKey.ADMIN}",
"/datasets/tag",
json={"data_id": dataset_id, "tag": tag},
headers={"Authorization": ApiKey.ADMIN},
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Test for missing Authorization header is present, but consider testing for extra/invalid headers.

Consider adding a test where the Authorization header contains an unexpected or malformed value to verify the endpoint correctly rejects such requests.

)
assert response.status_code == HTTPStatus.OK
assert response.json() == {"data_tag": {"id": str(dataset_id), "tag": ["study_14", tag]}}
Expand All @@ -55,8 +58,9 @@ def test_dataset_tag_returns_existing_tags(py_api: TestClient) -> None:
def test_dataset_tag_fails_if_tag_exists(py_api: TestClient) -> None:
dataset_id, tag = 1, "study_14" # Dataset 1 already is tagged with 'study_14'
response = py_api.post(
f"/datasets/tag?api_key={ApiKey.ADMIN}",
"/datasets/tag",
json={"data_id": dataset_id, "tag": tag},
headers={"Authorization": ApiKey.ADMIN},
)
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
expected = {
Expand All @@ -79,8 +83,9 @@ def test_dataset_tag_invalid_tag_is_rejected(
py_api: TestClient,
) -> None:
new = py_api.post(
f"/datasets/tag?api_key{ApiKey.ADMIN}",
"/datasets/tag",
json={"data_id": 1, "tag": tag},
headers={"Authorization": ApiKey.ADMIN},
)

assert new.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
Expand Down
26 changes: 17 additions & 9 deletions tests/routers/openml/datasets_list_datasets_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,11 @@ def test_list_filter_active(status: str, amount: int, py_api: TestClient) -> Non
],
)
def test_list_accounts_privacy(api_key: ApiKey | None, amount: int, py_api: TestClient) -> None:
key = f"?api_key={api_key}" if api_key else ""
headers = {"Authorization": api_key} if api_key else {}
response = py_api.post(
f"/datasets/list{key}",
"/datasets/list",
json={"status": "all", "pagination": {"limit": 1000}},
headers=headers,
)
assert response.status_code == HTTPStatus.OK, response.json()
assert len(response.json()) == amount
Comment on lines 55 to 63
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Consider adding tests for malformed or missing Authorization headers.

Tests should also cover cases like empty, incorrectly formatted, or multiple Authorization headers to verify error handling and security.

Suggested change
def test_list_accounts_privacy(api_key: ApiKey | None, amount: int, py_api: TestClient) -> None:
key = f"?api_key={api_key}" if api_key else ""
headers = {"Authorization": api_key} if api_key else {}
response = py_api.post(
f"/datasets/list{key}",
"/datasets/list",
json={"status": "all", "pagination": {"limit": 1000}},
headers=headers,
)
assert response.status_code == HTTPStatus.OK, response.json()
assert len(response.json()) == amount
def test_list_accounts_privacy(api_key: ApiKey | None, amount: int, py_api: TestClient) -> None:
headers = {"Authorization": api_key} if api_key else {}
response = py_api.post(
"/datasets/list",
json={"status": "all", "pagination": {"limit": 1000}},
headers=headers,
)
assert response.status_code == HTTPStatus.OK, response.json()
assert len(response.json()) == amount
def test_list_accounts_privacy_missing_authorization(py_api: TestClient) -> None:
response = py_api.post(
"/datasets/list",
json={"status": "all", "pagination": {"limit": 1000}},
)
assert response.status_code in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN)
def test_list_accounts_privacy_empty_authorization(py_api: TestClient) -> None:
headers = {"Authorization": ""}
response = py_api.post(
"/datasets/list",
json={"status": "all", "pagination": {"limit": 1000}},
headers=headers,
)
assert response.status_code in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN)
def test_list_accounts_privacy_malformed_authorization(py_api: TestClient) -> None:
headers = {"Authorization": "Bearer"} # Missing token
response = py_api.post(
"/datasets/list",
json={"status": "all", "pagination": {"limit": 1000}},
headers=headers,
)
assert response.status_code in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN)
def test_list_accounts_privacy_incorrect_format_authorization(py_api: TestClient) -> None:
headers = {"Authorization": "Basic not_a_token"}
response = py_api.post(
"/datasets/list",
json={"status": "all", "pagination": {"limit": 1000}},
headers=headers,
)
assert response.status_code in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN)
def test_list_accounts_privacy_multiple_authorization_headers(py_api: TestClient) -> None:
# TestClient does not support multiple headers with the same name directly,
# but we can simulate by passing a comma-separated value.
headers = {"Authorization": "Bearer valid_token, Bearer another_token"}
response = py_api.post(
"/datasets/list",
json={"status": "all", "pagination": {"limit": 1000}},
headers=headers,
)
assert response.status_code in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN)

Expand All @@ -69,8 +70,9 @@ def test_list_accounts_privacy(api_key: ApiKey | None, amount: int, py_api: Test
def test_list_data_name_present(name: str, count: int, py_api: TestClient) -> None:
# The second iris dataset is private, so we need to authenticate.
response = py_api.post(
f"/datasets/list?api_key={ApiKey.ADMIN}",
"/datasets/list",
json={"status": "all", "data_name": name},
headers={"Authorization": ApiKey.ADMIN},
)
assert response.status_code == HTTPStatus.OK
datasets = response.json()
Expand All @@ -84,8 +86,9 @@ def test_list_data_name_present(name: str, count: int, py_api: TestClient) -> No
)
def test_list_data_name_absent(name: str, py_api: TestClient) -> None:
response = py_api.post(
f"/datasets/list?api_key={ApiKey.ADMIN}",
"/datasets/list",
json={"status": "all", "data_name": name},
headers={"Authorization": ApiKey.ADMIN},
)
_assert_empty_result(response)

Expand Down Expand Up @@ -123,8 +126,9 @@ def test_list_pagination(limit: int | None, offset: int | None, py_api: TestClie
)
def test_list_data_version(version: int, count: int, py_api: TestClient) -> None:
response = py_api.post(
f"/datasets/list?api_key={ApiKey.ADMIN}",
"/datasets/list",
json={"status": "all", "data_version": version},
headers={"Authorization": ApiKey.ADMIN},
)
assert response.status_code == HTTPStatus.OK
datasets = response.json()
Expand All @@ -134,8 +138,9 @@ def test_list_data_version(version: int, count: int, py_api: TestClient) -> None

def test_list_data_version_no_result(py_api: TestClient) -> None:
response = py_api.post(
f"/datasets/list?api_key={ApiKey.ADMIN}",
"/datasets/list",
json={"status": "all", "data_version": 4},
headers={"Authorization": ApiKey.ADMIN},
)
_assert_empty_result(response)

Expand All @@ -150,8 +155,9 @@ def test_list_data_version_no_result(py_api: TestClient) -> None:
)
def test_list_uploader(user_id: int, count: int, key: str, py_api: TestClient) -> None:
response = py_api.post(
f"/datasets/list?api_key={key}",
"/datasets/list",
json={"status": "all", "uploader": user_id},
headers={"Authorization": key},
)
# The dataset of user 16 is private, so can not be retrieved by other users.
owner_user_id = 16
Expand Down Expand Up @@ -255,7 +261,7 @@ def test_list_data_identical(
return hypothesis.reject()

api_key = kwargs.pop("api_key")
api_key_query = f"?api_key={api_key}" if api_key else ""
headers = {"Authorization": api_key} if api_key else {}

# Pagination parameters are nested in the new query style
# The old style has no `limit` by default, so we mimic this with a high default
Expand All @@ -264,8 +270,9 @@ def test_list_data_identical(
new_style["pagination"]["offset"] = offset

response = py_api.post(
f"/datasets/list{api_key_query}",
"/datasets/list",
json=new_style,
headers=headers,
)

# old style `/data/filter` encodes all filters as a path
Expand All @@ -274,6 +281,7 @@ def test_list_data_identical(
for filter_, value in kwargs.items()
if value is not None
]
api_key_query = f"?api_key={api_key}" if api_key else ""
uri = "/data/list"
if query:
uri += f"/{'/'.join([str(v) for q in query for v in q])}"
Expand Down
8 changes: 5 additions & 3 deletions tests/routers/openml/datasets_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ def test_dataset_features_no_access(py_api: TestClient) -> None:
[ApiKey.ADMIN, ApiKey.OWNER_USER],
)
def test_dataset_features_access_to_private(api_key: ApiKey, py_api: TestClient) -> None:
response = py_api.get(f"/datasets/features/130?api_key={api_key}")
response = py_api.get("/datasets/features/130", headers={"Authorization": api_key})
assert response.status_code == HTTPStatus.OK


Expand All @@ -194,8 +194,9 @@ def _assert_status_update_is_successful(
py_api: TestClient,
) -> None:
response = py_api.post(
f"/datasets/status/update?api_key={apikey}",
"/datasets/status/update",
json={"dataset_id": dataset_id, "status": status},
headers={"Authorization": apikey},
)
assert response.status_code == HTTPStatus.OK
assert response.json() == {
Expand Down Expand Up @@ -265,7 +266,8 @@ def test_dataset_status_unauthorized(
py_api: TestClient,
) -> None:
response = py_api.post(
f"/datasets/status/update?api_key={api_key}",
"/datasets/status/update",
json={"dataset_id": dataset_id, "status": status},
headers={"Authorization": api_key},
)
assert response.status_code == HTTPStatus.FORBIDDEN
9 changes: 5 additions & 4 deletions tests/routers/openml/migration/datasets_migration_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,8 @@ def test_private_dataset_no_user_no_access(
py_api: TestClient,
api_key: str | None,
) -> None:
query = f"?api_key={api_key}" if api_key else ""
response = py_api.get(f"/datasets/130{query}")
headers = {"Authorization": api_key} if api_key else {}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Consider adding a test for both query parameter and header present.

Add a test case with both the query parameter and Authorization header to confirm the correct precedence and maintain backward compatibility.

response = py_api.get("/datasets/130", headers=headers)

# New response is 403: Forbidden instead of 412: PRECONDITION FAILED
assert response.status_code == HTTPStatus.FORBIDDEN
Expand All @@ -128,7 +128,7 @@ def test_private_dataset_owner_access(
api_key: str,
) -> None:
[private_dataset] = constants.PRIVATE_DATASET_ID
new_response = py_api.get(f"/datasets/{private_dataset}?api_key={api_key}")
new_response = py_api.get(f"/datasets/{private_dataset}", headers={"Authorization": api_key})
old_response = php_api.get(f"/data/{private_dataset}?api_key={api_key}")
assert old_response.status_code == HTTPStatus.OK
assert old_response.status_code == new_response.status_code
Expand Down Expand Up @@ -177,8 +177,9 @@ def test_dataset_tag_response_is_identical(
):
pytest.skip("Encountered Elastic Search error.")
new = py_api.post(
f"/datasets/tag?api_key={api_key}",
"/datasets/tag",
json={"data_id": dataset_id, "tag": tag},
headers={"Authorization": api_key},
)

assert original.status_code == new.status_code, original.json()
Expand Down
6 changes: 4 additions & 2 deletions tests/routers/openml/study_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,7 @@ def test_get_task_study_by_alias(py_api: TestClient) -> None:

def test_create_task_study(py_api: TestClient) -> None:
response = py_api.post(
"/studies?api_key=00000000000000000000000000000000",
"/studies",
json={
"name": "Test Study",
"alias": "test-study",
Expand All @@ -467,6 +467,7 @@ def test_create_task_study(py_api: TestClient) -> None:
"tasks": [1, 2, 3],
"runs": [],
},
headers={"Authorization": "00000000000000000000000000000000"},
)
assert response.status_code == HTTPStatus.OK
new = response.json()
Expand Down Expand Up @@ -512,8 +513,9 @@ def _attach_tasks_to_study(
# but the current snapshot has no in-preparation studies.
expdb_test.execute(text("UPDATE study SET status = 'in_preparation' WHERE id = 1"))
return py_api.post(
f"/studies/attach?api_key={api_key}",
"/studies/attach",
json={"study_id": study_id, "entity_ids": task_ids},
headers={"Authorization": api_key},
)


Expand Down