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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/journal
/db.sqlite3*
Session.vim
/.venv
/.venv*
/assets
/logs
/.coverage
Expand All @@ -10,6 +10,8 @@ Session.vim
/.idea

__pycache__
.pytest_cache/
.ruff_cache/
.*.swp

/etebase_server_settings.py
Expand All @@ -18,3 +20,4 @@ __pycache__
/build
/dist
/*.egg-info
.DS_Store
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ An [Etebase](https://www.etebase.com) (EteSync 2.0) server so you can run your o

## Requirements

Etebase requires Python 3.7 or newer and has a few Python dependencies (listed in `requirements.in/base.txt`).
Etebase requires Python 3.10 or newer and has a few Python dependencies (listed in `requirements.in/base.txt`).

## From source

Expand Down
4 changes: 2 additions & 2 deletions etebase_server/fastapi/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from django.db.models import QuerySet
from django.utils import timezone
from fastapi import Depends
from fastapi import Depends, Path
from fastapi.security import APIKeyHeader

from etebase_server.django import models
Expand Down Expand Up @@ -74,7 +74,7 @@ def get_collection_queryset(user: UserType = Depends(get_authenticated_user)) ->


@django_db_cleanup_decorator
def get_collection(collection_uid: str, queryset: QuerySet = Depends(get_collection_queryset)) -> models.Collection:
def get_collection(collection_uid: str = Path(...), queryset: QuerySet = Depends(get_collection_queryset)) -> models.Collection:
return get_object_or_404(queryset, uid=collection_uid)


Expand Down
6 changes: 2 additions & 4 deletions etebase_server/fastapi/routers/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,8 @@ def signup_save(data: SignupIn, request: Request) -> UserType:
with transaction.atomic():
try:
user_queryset = get_user_queryset(User.objects.all(), CallbackContext(request.path_params))
instance = user_queryset.get(**{User.USERNAME_FIELD: user_data.username.lower()})
user_queryset.get(**{User.USERNAME_FIELD: user_data.username.lower()})
raise HttpError("user_exists", "User already exists", status_code=status.HTTP_409_CONFLICT)
except User.DoesNotExist:
# Create the user and save the casing the user chose as the first name
try:
Expand All @@ -249,9 +250,6 @@ def signup_save(data: SignupIn, request: Request) -> UserType:
except Exception as e:
raise HttpError("generic", str(e))

if hasattr(instance, "userinfo"):
raise HttpError("user_exists", "User already exists", status_code=status.HTTP_409_CONFLICT)

models.UserInfo.objects.create(**data.dict(exclude={"user"}), owner=instance)
return instance

Expand Down
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,9 @@ ignore = ["E203", "E501", "E711", "E712", "N803", "N815", "N818", "T201"]

[tool.ruff.lint.isort]
combine-as-imports = true

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
addopts = "-ra"
DJANGO_SETTINGS_MODULE = "etebase_server.settings"
404 changes: 380 additions & 24 deletions requirements-dev.txt

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions requirements.in/base.txt
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
django>=4.0,<5.0
django>=5.2,<6.0
msgpack
pynacl
fastapi>=0.104
fastapi==0.138.2
pydantic>=2.0.0
typing_extensions
uvicorn[standard]
aiofiles
redis>=4.2.0rc1
redis>=5.0
5 changes: 5 additions & 0 deletions requirements.in/development.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Keep dev-only deps aligned with the runtime pins in requirements.txt.
--constraint ../requirements.txt

coverage
pip-tools
pytest
pytest-django
pywatchman
ruff
mypy
Expand Down
791 changes: 711 additions & 80 deletions requirements.txt

Large diffs are not rendered by default.

100 changes: 100 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Shared fixtures for the FastAPI endpoint tests.

Provides:
- ``app`` : the real ASGI app built by ``create_application`` (session-scoped).
- ``api_request`` : a synchronous ASGI driver to issue msgpack requests without httpx.
- ``user`` / ``auth_token`` : user and token created via the ORM (they need a transactional
DB, because the sync endpoints run in the threadpool and must see committed data).
"""

import asyncio
import os

import msgpack
import pytest
from django.conf import settings

from etebase_server.fastapi.utils import msgpack_encode


@pytest.fixture(scope="session")
def app():
# create_application mounts StaticFiles on STATIC_ROOT and installs TrustedHostMiddleware:
# in tests the dir does not exist and ALLOWED_HOSTS is empty, so we set them before building.
settings.ALLOWED_HOSTS = ["*"]
os.makedirs(settings.STATIC_ROOT, exist_ok=True)

from etebase_server.fastapi.main import create_application

return create_application()


@pytest.fixture
def api_request(app):
"""Drive a single HTTP request through the ASGI app and return ``(status, decoded_body)``.

The request body is msgpack-encoded; the response is msgpack-decoded (falling back to raw
bytes for non-msgpack responses, e.g. FastAPI's default 401/403).
"""

def _request(method, path, *, body=None, token=None):
raw = msgpack_encode(body) if body is not None else b""
url_path, _, query = path.partition("?")

headers = [(b"host", b"testserver"), (b"accept", b"application/msgpack")]
if body is not None:
headers.append((b"content-type", b"application/msgpack"))
if token is not None:
headers.append((b"authorization", f"Token {token}".encode()))

scope = {
"type": "http",
"asgi": {"version": "3.0", "spec_version": "2.3"},
"http_version": "1.1",
"method": method,
"path": url_path,
"raw_path": url_path.encode(),
"query_string": query.encode(),
"headers": headers,
"scheme": "http",
"server": ("testserver", 80),
"client": ("testclient", 1),
"root_path": "",
}

async def receive():
return {"type": "http.request", "body": raw, "more_body": False}

captured = {"status": None, "body": b""}

async def send(message):
if message["type"] == "http.response.start":
captured["status"] = message["status"]
elif message["type"] == "http.response.body":
captured["body"] += message.get("body", b"")

asyncio.run(app(scope, receive, send))

out = captured["body"]
try:
decoded = msgpack.unpackb(out, raw=False) if out else None
except Exception:
decoded = out
return captured["status"], decoded

return _request


@pytest.fixture
def user(transactional_db):
from etebase_server.myauth.models import get_typed_user_model

User = get_typed_user_model()
return User.objects.create(username="testuser", email="testuser@example.com")


@pytest.fixture
def auth_token(user):
from etebase_server.django.token_auth.models import AuthToken

return AuthToken.objects.create(user=user).key
Empty file added tests/integration/__init__.py
Empty file.
59 changes: 59 additions & 0 deletions tests/integration/test_item_endpoints.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Integration smoke tests for the collection/item endpoints.

They drive real msgpack requests through the ASGI app (auth + dependency resolution + ORM).
The key signal is **404 vs 422**: before the ``collection_uid`` fix the item endpoints returned
422; with the fix, a nonexistent collection returns 404.
"""

# Plausible but nonexistent uid: it must be resolved as a path param (404), not 422.
NONEXISTENT = "0" * 64


def test_item_list_nonexistent_collection_returns_404_not_422(api_request, auth_token):
status, _ = api_request("GET", f"/api/v1/collection/{NONEXISTENT}/item/", token=auth_token)
assert status == 404


def test_item_batch_wellformed_body_returns_404_not_422(api_request, auth_token):
body = {
"items": [
{
"uid": "item-uid",
"version": 1,
"encryptionKey": b"key",
"content": {
"uid": "item-uid",
"meta": b"meta",
"deleted": False,
"chunks": [["chunk-uid", b"data"]],
},
"etag": None,
}
],
"deps": None,
}
status, _ = api_request(
"POST", f"/api/v1/collection/{NONEXISTENT}/item/batch/", body=body, token=auth_token
)
assert status == 404


def test_list_multi_returns_200(api_request, auth_token):
status, decoded = api_request(
"POST", "/api/v1/collection/list_multi/", body={"collectionTypes": []}, token=auth_token
)
assert status == 200
assert decoded["data"] == []


def test_item_list_without_token_returns_401(api_request):
# Missing Authorization header -> APIKeyHeader (auto_error) responds 401.
status, _ = api_request("GET", f"/api/v1/collection/{NONEXISTENT}/item/")
assert status == 401


def test_member_list_nonexistent_collection_returns_404_not_422(api_request, auth_token):
# member_router is also mounted under /collection/{collection_uid}: same 422 risk,
# covered by the same get_collection fix.
status, _ = api_request("GET", f"/api/v1/collection/{NONEXISTENT}/member/", token=auth_token)
assert status == 404
Loading