diff --git a/README.md b/README.md index 9b36752..773a626 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,134 @@ Running the below commands should get the development environment running using 2. `$ make fixtures` to create a small number of fixture file versions. 3. `$ make serve` to start the development server on port 8001. 4. `$ make test` to run the limited test suite via PyTest. + +### Backend API Testing (Copy/Paste Guide) +Use the steps below exactly as written. + +#### 1) Open a terminal and go to the project root +```sh +cd /home/zainab/Desktop/document-manager-assessment/document-manager-assessment +``` + +#### 2) Install/sync dependencies +```sh +make build +``` + +#### 3) Apply migrations +```sh +uv run django-admin migrate +``` + +#### 4) Create two users and API tokens +Run this command and copy the two printed tokens: +```sh +uv run django-admin shell -c "from django.contrib.auth import get_user_model; from rest_framework.authtoken.models import Token; U=get_user_model(); u1,_=U.objects.get_or_create(email='owner@example.com', defaults={'name':'Owner'}); u1.set_password('ownerpass123'); u1.save(); t1,_=Token.objects.get_or_create(user=u1); u2,_=U.objects.get_or_create(email='other@example.com', defaults={'name':'Other'}); u2.set_password('otherpass123'); u2.save(); t2,_=Token.objects.get_or_create(user=u2); print('OWNER_TOKEN=' + t1.key); print('OTHER_TOKEN=' + t2.key)" +``` + +Export the tokens in your shell (replace values): +```sh +export OWNER_TOKEN="paste_owner_token_here" +export OTHER_TOKEN="paste_other_token_here" +``` + +#### 5) Start the server +Keep this terminal running: +```sh +make serve +``` + +#### 6) Open a second terminal and go to the project root +```sh +cd /home/zainab/Desktop/document-manager-assessment/document-manager-assessment +``` + +#### 7) Confirm unauthenticated requests are blocked +```sh +curl -i http://127.0.0.1:8001/api/file_versions/ +curl -i http://127.0.0.1:8001/api/documents/reviews/review.pdf +``` +Expected: `401` or `403`. + +#### 8) Upload any file type to any URL as owner +```sh +curl -i -X POST http://127.0.0.1:8001/api/documents/reviews/review.pdf \ + -H "Authorization: Token $OWNER_TOKEN" \ + -F "file=@/home/zainab/Downloads/kittys.jpg" +``` +Expected: `201` and JSON with fields including `url_path`, `version_number`, `content_hash`. + +#### 9) Upload second version to the same URL +```sh +curl -i -X POST http://127.0.0.1:8001/api/documents/reviews/review.pdf \ + -H "Authorization: Token $OWNER_TOKEN" \ + -F "file=@/home/zainab/Downloads/kittys.jpg" +``` +Expected: `201` and `version_number` should be `2`. + +#### 10) List owner files +```sh +curl -s http://127.0.0.1:8001/api/file_versions/ \ + -H "Authorization: Token $OWNER_TOKEN" +``` +Expected: JSON list with your records only. + +#### 11) Fetch latest file bytes by URL +```sh +curl -i http://127.0.0.1:8001/api/documents/reviews/review.pdf \ + -H "Authorization: Token $OWNER_TOKEN" \ + -o /tmp/latest_review.bin +``` + +#### 12) Fetch specific revisions by URL +```sh +curl -i "http://127.0.0.1:8001/api/documents/reviews/review.pdf?revision=0" \ + -H "Authorization: Token $OWNER_TOKEN" \ + -o /tmp/review_rev0.bin + +curl -i "http://127.0.0.1:8001/api/documents/reviews/review.pdf?revision=1" \ + -H "Authorization: Token $OWNER_TOKEN" \ + -o /tmp/review_rev1.bin +``` + +#### 13) Fetch metadata JSON +```sh +curl -s http://127.0.0.1:8001/api/documents/reviews/review.pdf/metadata \ + -H "Authorization: Token $OWNER_TOKEN" + +curl -s "http://127.0.0.1:8001/api/documents/reviews/review.pdf/metadata?revision=0" \ + -H "Authorization: Token $OWNER_TOKEN" +``` + +#### 14) Fetch by CAS hash +First copy `content_hash` from metadata response above, then: +```sh +export CONTENT_HASH="paste_content_hash_here" + +curl -i http://127.0.0.1:8001/api/cas/$CONTENT_HASH \ + -H "Authorization: Token $OWNER_TOKEN" \ + -o /tmp/cas_file.bin +``` + +#### 15) Verify other users cannot access owner files +```sh +curl -i http://127.0.0.1:8001/api/documents/reviews/review.pdf \ + -H "Authorization: Token $OTHER_TOKEN" + +curl -i http://127.0.0.1:8001/api/documents/reviews/review.pdf/metadata \ + -H "Authorization: Token $OTHER_TOKEN" + +curl -i http://127.0.0.1:8001/api/cas/$CONTENT_HASH \ + -H "Authorization: Token $OTHER_TOKEN" +``` +Expected: `404` for these owner-only resources. + +#### 16) Run unit tests +```sh +PYTHONPATH=src python3 -m pytest tests/test_file_versions.py -q +``` +Expected: all tests pass. + ### Client Development See the Readme [here](https://github.com/propylon/document-manager-assessment/blob/main/client/doc-manager/README.md) diff --git a/src/propylon_document_manager/file_versions/api/serializers.py b/src/propylon_document_manager/file_versions/api/serializers.py index 612bd6b..2a04edf 100644 --- a/src/propylon_document_manager/file_versions/api/serializers.py +++ b/src/propylon_document_manager/file_versions/api/serializers.py @@ -1,8 +1,47 @@ +import os + from rest_framework import serializers from ..models import FileVersion + class FileVersionSerializer(serializers.ModelSerializer): + # Serializer for upload + metadata responses; version/content hash stay server-controlled. + file = serializers.FileField(write_only=True) + file_name = serializers.CharField(required=False, allow_blank=True) + url_path = serializers.CharField(required=False, allow_blank=True, default="") + file_url = serializers.SerializerMethodField(read_only=True) + class Meta: model = FileVersion - fields = "__all__" + fields = ["id", "file", "file_name", "url_path", "content_hash", "version_number", "content_type", "file_size", "file_url", "uploaded_at"] + read_only_fields = ["id", "content_hash", "version_number", "content_type", "file_size", "file_url", "uploaded_at"] + validators = [] + + # get_file_url method to return the absolute URL of the uploaded file + def get_file_url(self, obj): + request = self.context.get("request") + if request is not None and obj.file: + return request.build_absolute_uri(obj.file.url) + if obj.file: + return obj.file.url + return None + + # create method to handle file upload and versioning logic + def create(self, validated_data): + # Normalize URL path and infer file_name when caller omits it. + uploaded_file = validated_data.pop("file") + owner = validated_data.pop("owner", None) + url_path = validated_data.pop("url_path", "") or "" + url_path = url_path.strip("/") + file_name = validated_data.pop("file_name", None) or os.path.basename(url_path) or uploaded_file.name + file_version = FileVersion( + owner=owner, + url_path=url_path, + file=uploaded_file, + file_name=file_name, + content_type=getattr(uploaded_file, "content_type", None) or "application/octet-stream", + file_size=uploaded_file.size, + ) + file_version.save() + return file_version diff --git a/src/propylon_document_manager/file_versions/api/views.py b/src/propylon_document_manager/file_versions/api/views.py index c46ee2b..5ee097f 100644 --- a/src/propylon_document_manager/file_versions/api/views.py +++ b/src/propylon_document_manager/file_versions/api/views.py @@ -1,14 +1,133 @@ -from django.shortcuts import render +import os -from rest_framework.mixins import RetrieveModelMixin, ListModelMixin +from django.http import FileResponse, Http404 +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.parsers import FormParser, MultiPartParser +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework.mixins import CreateModelMixin, ListModelMixin, RetrieveModelMixin from rest_framework.viewsets import GenericViewSet from ..models import FileVersion from .serializers import FileVersionSerializer -class FileVersionViewSet(RetrieveModelMixin, ListModelMixin, GenericViewSet): - authentication_classes = [] - permission_classes = [] + +class FileVersionViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, GenericViewSet): + # Generic file-version API: authenticated users can only list/retrieve their own records. + permission_classes = [IsAuthenticated] + parser_classes = (MultiPartParser, FormParser) serializer_class = FileVersionSerializer - queryset = FileVersion.objects.all() + queryset = FileVersion.objects.none() lookup_field = "id" + + def get_queryset(self): + return FileVersion.objects.filter(owner=self.request.user).order_by("-uploaded_at", "-version_number") + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) + + +class DocumentView(APIView): + # URL-addressed upload/retrieval endpoint with revision support. + permission_classes = [IsAuthenticated] + parser_classes = (MultiPartParser, FormParser) + + def post(self, request, url_path): + url_path = url_path.strip("/") + uploaded_file = request.FILES.get("file") + if not uploaded_file: + return Response({"detail": "No file provided."}, status=status.HTTP_400_BAD_REQUEST) + + file_name = os.path.basename(url_path) or uploaded_file.name + file_version = FileVersion( + owner=request.user, + url_path=url_path, + file=uploaded_file, + file_name=file_name, + content_type=getattr(uploaded_file, "content_type", None) or "application/octet-stream", + file_size=uploaded_file.size, + ) + file_version.save() + + serializer = FileVersionSerializer(file_version, context={"request": request}) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def get(self, request, url_path): + url_path = url_path.strip("/") + versions = FileVersion.objects.filter(owner=request.user, url_path=url_path).order_by("version_number") + if not versions.exists(): + raise Http404("Document not found.") + + revision = request.query_params.get("revision") + if revision is not None: + try: + revision_index = int(revision) + except ValueError: + return Response({"detail": "Invalid revision."}, status=status.HTTP_400_BAD_REQUEST) + + if revision_index < 0 or revision_index >= versions.count(): + raise Http404("Revision not found.") + file_version = versions[revision_index] + else: + file_version = versions.last() + + if not file_version.file: + raise Http404("File not found.") + + return FileResponse( + file_version.file.open("rb"), + content_type=file_version.content_type or "application/octet-stream", + as_attachment=True, + filename=file_version.file_name, + ) + + +class DocumentMetadataView(APIView): + # Metadata-only representation for latest or specific revision. + permission_classes = [IsAuthenticated] + + def get(self, request, url_path): + url_path = url_path.strip("/") + versions = FileVersion.objects.filter(owner=request.user, url_path=url_path).order_by("version_number") + if not versions.exists(): + raise Http404("Document not found.") + + revision = request.query_params.get("revision") + if revision is not None: + try: + revision_index = int(revision) + except ValueError: + return Response({"detail": "Invalid revision."}, status=status.HTTP_400_BAD_REQUEST) + + if revision_index < 0 or revision_index >= versions.count(): + raise Http404("Revision not found.") + file_version = versions[revision_index] + else: + file_version = versions.last() + + serializer = FileVersionSerializer(file_version, context={"request": request}) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class CASDocumentView(APIView): + # Content-addressable retrieval by SHA-256 hash, scoped to the requesting owner. + permission_classes = [IsAuthenticated] + + def get(self, request, content_hash): + file_version = ( + FileVersion.objects.filter(owner=request.user, content_hash=content_hash) + .order_by("-uploaded_at", "-version_number") + .first() + ) + if file_version is None: + raise Http404("Document not found.") + if not file_version.file: + raise Http404("File not found.") + + return FileResponse( + file_version.file.open("rb"), + content_type=file_version.content_type or "application/octet-stream", + as_attachment=True, + filename=file_version.file_name, + ) diff --git a/src/propylon_document_manager/file_versions/migrations/0002_fileversion_storage_fields.py b/src/propylon_document_manager/file_versions/migrations/0002_fileversion_storage_fields.py new file mode 100644 index 0000000..7b4f9cd --- /dev/null +++ b/src/propylon_document_manager/file_versions/migrations/0002_fileversion_storage_fields.py @@ -0,0 +1,42 @@ +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("file_versions", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="fileversion", + name="url_path", + field=models.CharField(blank=True, default="", db_index=True, max_length=1024), + ), + migrations.AddField( + model_name="fileversion", + name="content_type", + field=models.CharField(blank=True, default="", max_length=255), + ), + migrations.AddField( + model_name="fileversion", + name="file", + field=models.FileField(blank=True, null=True, upload_to="uploads/%Y/%m/%d"), + ), + migrations.AddField( + model_name="fileversion", + name="file_size", + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name="fileversion", + name="uploaded_at", + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AlterField( + model_name="fileversion", + name="version_number", + field=models.PositiveIntegerField(default=1), + ), + ] diff --git a/src/propylon_document_manager/file_versions/migrations/0003_alter_fileversion_options.py b/src/propylon_document_manager/file_versions/migrations/0003_alter_fileversion_options.py new file mode 100644 index 0000000..f6f29ff --- /dev/null +++ b/src/propylon_document_manager/file_versions/migrations/0003_alter_fileversion_options.py @@ -0,0 +1,17 @@ +# Generated by Django 6.0.6 on 2026-06-30 09:26 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("file_versions", "0002_fileversion_storage_fields"), + ] + + operations = [ + migrations.AlterModelOptions( + name="fileversion", + options={"ordering": ["-uploaded_at", "-version_number"]}, + ), + ] diff --git a/src/propylon_document_manager/file_versions/migrations/0004_fileversion_unique_version_constraints.py b/src/propylon_document_manager/file_versions/migrations/0004_fileversion_unique_version_constraints.py new file mode 100644 index 0000000..a1f7a82 --- /dev/null +++ b/src/propylon_document_manager/file_versions/migrations/0004_fileversion_unique_version_constraints.py @@ -0,0 +1,26 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("file_versions", "0003_alter_fileversion_options"), + ] + + operations = [ + migrations.AddConstraint( + model_name="fileversion", + constraint=models.UniqueConstraint( + condition=~models.Q(url_path=""), + fields=("url_path", "version_number"), + name="uniq_fileversion_url_path_version", + ), + ), + migrations.AddConstraint( + model_name="fileversion", + constraint=models.UniqueConstraint( + condition=models.Q(url_path=""), + fields=("file_name", "version_number"), + name="uniq_fileversion_name_version_no_url", + ), + ), + ] diff --git a/src/propylon_document_manager/file_versions/migrations/0005_fileversion_owner_constraints.py b/src/propylon_document_manager/file_versions/migrations/0005_fileversion_owner_constraints.py new file mode 100644 index 0000000..08d9bea --- /dev/null +++ b/src/propylon_document_manager/file_versions/migrations/0005_fileversion_owner_constraints.py @@ -0,0 +1,47 @@ +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("file_versions", "0004_fileversion_unique_version_constraints"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="fileversion", + name="owner", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=models.deletion.CASCADE, + related_name="file_versions", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.RemoveConstraint( + model_name="fileversion", + name="uniq_fileversion_url_path_version", + ), + migrations.RemoveConstraint( + model_name="fileversion", + name="uniq_fileversion_name_version_no_url", + ), + migrations.AddConstraint( + model_name="fileversion", + constraint=models.UniqueConstraint( + condition=(~models.Q(url_path="") & models.Q(owner__isnull=False)), + fields=("owner", "url_path", "version_number"), + name="uniq_fileversion_owner_url_path_version", + ), + ), + migrations.AddConstraint( + model_name="fileversion", + constraint=models.UniqueConstraint( + condition=(models.Q(url_path="") & models.Q(owner__isnull=False)), + fields=("owner", "file_name", "version_number"), + name="uniq_fileversion_owner_name_version_no_url", + ), + ), + ] diff --git a/src/propylon_document_manager/file_versions/migrations/0006_fileversion_content_hash.py b/src/propylon_document_manager/file_versions/migrations/0006_fileversion_content_hash.py new file mode 100644 index 0000000..b6c56cf --- /dev/null +++ b/src/propylon_document_manager/file_versions/migrations/0006_fileversion_content_hash.py @@ -0,0 +1,15 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("file_versions", "0005_fileversion_owner_constraints"), + ] + + operations = [ + migrations.AddField( + model_name="fileversion", + name="content_hash", + field=models.CharField(blank=True, db_index=True, default="", max_length=64), + ), + ] diff --git a/src/propylon_document_manager/file_versions/models.py b/src/propylon_document_manager/file_versions/models.py index de38112..66a4f3a 100644 --- a/src/propylon_document_manager/file_versions/models.py +++ b/src/propylon_document_manager/file_versions/models.py @@ -1,9 +1,13 @@ -from django.db import models from django.contrib.auth.models import AbstractUser +import hashlib +from django.conf import settings +from django.db import IntegrityError, models, transaction from django.db.models import CharField, EmailField +from django.db.models import Q from django.urls import reverse from django.utils.translation import gettext_lazy as _ + class User(AbstractUser): """ Default custom user model for Propylon Document Manager. @@ -30,7 +34,82 @@ def get_absolute_url(self) -> str: """ return reverse("users:detail", kwargs={"pk": self.id}) - class FileVersion(models.Model): - file_name = models.fields.CharField(max_length=512) - version_number = models.fields.IntegerField() + # Owner-scoped document version record supporting URL-based storage and CAS lookup. + owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="file_versions", null=True, blank=True) + url_path = models.CharField(max_length=1024, blank=True, default="", db_index=True) + file = models.FileField(upload_to="uploads/%Y/%m/%d", blank=True, null=True) + content_hash = models.CharField(max_length=64, blank=True, default="", db_index=True) + file_name = models.CharField(max_length=512) + version_number = models.PositiveIntegerField(default=1) + content_type = models.CharField(max_length=255, blank=True, default="") + file_size = models.PositiveIntegerField(default=0) + uploaded_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-uploaded_at", "-version_number"] + constraints = [ + # Prevent duplicate version numbers for the same owner+URL. + models.UniqueConstraint( + fields=["owner", "url_path", "version_number"], + condition=~Q(url_path="") & Q(owner__isnull=False), + name="uniq_fileversion_owner_url_path_version", + ), + # Fallback constraint when URL is blank and versioning is by file_name. + models.UniqueConstraint( + fields=["owner", "file_name", "version_number"], + condition=Q(url_path="") & Q(owner__isnull=False), + name="uniq_fileversion_owner_name_version_no_url", + ), + ] + + def save(self, *args, **kwargs): + if not self._state.adding: + save_result = super().save(*args, **kwargs) + if self.file and not self.content_hash: + self.content_hash = self._calculate_content_hash() + FileVersion.objects.filter(pk=self.pk).update(content_hash=self.content_hash) + return save_result + + # Assign versions transactionally to avoid collisions under concurrent uploads. + for _ in range(3): + try: + with transaction.atomic(): + base_qs = FileVersion.objects.select_for_update() + if self.owner_id is not None: + base_qs = base_qs.filter(owner_id=self.owner_id) + else: + base_qs = base_qs.filter(owner__isnull=True) + + if self.url_path: + latest_version = ( + base_qs.filter(url_path=self.url_path) + .order_by("-version_number") + .first() + ) + else: + latest_version = ( + base_qs.filter(url_path="", file_name=self.file_name) + .order_by("-version_number") + .first() + ) + self.version_number = (latest_version.version_number if latest_version else 0) + 1 + save_result = super().save(*args, **kwargs) + if self.file and not self.content_hash: + self.content_hash = self._calculate_content_hash() + FileVersion.objects.filter(pk=self.pk).update(content_hash=self.content_hash) + return save_result + except IntegrityError: + continue + raise IntegrityError("Could not assign a unique version_number after retries.") + + def _calculate_content_hash(self) -> str: + # SHA-256 digest is used by the content-addressable retrieval endpoint. + hasher = hashlib.sha256() + with self.file.open("rb") as file_handle: + for chunk in iter(lambda: file_handle.read(8192), b""): + hasher.update(chunk) + return hasher.hexdigest() + + def __str__(self) -> str: + return self.file_name or self.file.name or "Untitled file" diff --git a/src/propylon_document_manager/site/api_router.py b/src/propylon_document_manager/site/api_router.py index 36a750f..a8daf21 100644 --- a/src/propylon_document_manager/site/api_router.py +++ b/src/propylon_document_manager/site/api_router.py @@ -1,7 +1,7 @@ from django.conf import settings from rest_framework.routers import DefaultRouter, SimpleRouter -from propylon_document_manager.file_versions.api.views import FileVersionViewSet +from propylon_document_manager.file_versions.api.views import DocumentView, FileVersionViewSet if settings.DEBUG: router = DefaultRouter() @@ -10,6 +10,5 @@ router.register("file_versions", FileVersionViewSet) - app_name = "api" urlpatterns = router.urls diff --git a/src/propylon_document_manager/site/urls.py b/src/propylon_document_manager/site/urls.py index 855593c..6bb0597 100644 --- a/src/propylon_document_manager/site/urls.py +++ b/src/propylon_document_manager/site/urls.py @@ -5,15 +5,25 @@ from django.views import defaults as default_views from django.views.generic import TemplateView from rest_framework.authtoken.views import obtain_auth_token +from propylon_document_manager.file_versions.api.views import CASDocumentView, DocumentMetadataView, DocumentView # API URLS urlpatterns = [ + path("api/cas/", CASDocumentView.as_view()), + path("api/cas//", CASDocumentView.as_view()), + path("api/documents//metadata", DocumentMetadataView.as_view()), + path("api/documents//metadata/", DocumentMetadataView.as_view()), + path("api/documents/", DocumentView.as_view()), + path("api/documents//", DocumentView.as_view()), # API base url path("api/", include("propylon_document_manager.site.api_router")), # DRF auth token path("api-auth/", include("rest_framework.urls")), path("auth-token/", obtain_auth_token), -] +] + +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) if settings.DEBUG: if "debug_toolbar" in settings.INSTALLED_APPS: diff --git a/tests/conftest.py b/tests/conftest.py index 27cfd36..6b3f1d7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,10 @@ +import os + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") + +import django +django.setup() + import pytest from propylon_document_manager.file_versions.models import User diff --git a/tests/factories.py b/tests/factories.py index caa14cf..d58c6d8 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -1,5 +1,4 @@ -from collections.abc import Sequence -from typing import Any +from typing import Any, Sequence from django.contrib.auth import get_user_model from factory import Faker, post_generation diff --git a/tests/test_file_versions.py b/tests/test_file_versions.py index 4e34d5a..a113f30 100644 --- a/tests/test_file_versions.py +++ b/tests/test_file_versions.py @@ -1,13 +1,215 @@ +from django.core.files.uploadedfile import SimpleUploadedFile +from rest_framework.test import APIClient + from propylon_document_manager.file_versions.models import FileVersion +from .factories import UserFactory + + +def _auth_client(email: str) -> APIClient: + user = UserFactory(email=email) + client = APIClient() + client.force_authenticate(user=user) + return client + -def test_file_versions(): +def test_file_versions(user): file_name = "new_file" file_version = 1 - FileVersion.objects.create( - file_name=file_name, - version_number=file_version - ) + FileVersion.objects.create(owner=user, file_name=file_name, version_number=file_version) files = FileVersion.objects.all() assert files.count() == 1 assert files[0].file_name == file_name assert files[0].version_number == file_version + + +def test_file_versions_api_can_upload_files(): + client = _auth_client("user1@example.com") + uploaded_file = SimpleUploadedFile("notes.txt", b"hello world", content_type="text/plain") + + response = client.post("/api/file_versions/", {"file": uploaded_file}, format="multipart") + + assert response.status_code == 201 + assert response.data["file_name"] == "notes.txt" + assert response.data["content_type"] == "text/plain" + assert response.data["version_number"] == 1 + assert FileVersion.objects.count() == 1 + + +def test_file_versions_api_can_upload_files_to_any_url(): + client = _auth_client("user2@example.com") + uploaded_file = SimpleUploadedFile("review.pdf", b"pdf-content", content_type="application/pdf") + response = client.post( + "/api/documents/reviews/review.pdf", + {"file": uploaded_file}, + format="multipart", + ) + + assert response.status_code == 201 + assert response.data["file_name"] == "review.pdf" + assert response.data["url_path"] == "reviews/review.pdf" + assert response.data["version_number"] == 1 + assert response.data["content_type"] == "application/pdf" + assert FileVersion.objects.filter(url_path="reviews/review.pdf").count() == 1 + + +def test_file_versions_api_supports_multiple_versions_on_same_url(): + client = _auth_client("user3@example.com") + first_file = SimpleUploadedFile("review.pdf", b"first-version", content_type="application/pdf") + second_file = SimpleUploadedFile("review.pdf", b"second-version", content_type="application/pdf") + + first_resp = client.post( + "/api/documents/reviews/review.pdf", + {"file": first_file}, + format="multipart", + ) + second_resp = client.post( + "/api/documents/reviews/review.pdf", + {"file": second_file}, + format="multipart", + ) + + assert first_resp.status_code == 201 + assert first_resp.data["version_number"] == 1 + assert second_resp.status_code == 201 + assert second_resp.data["version_number"] == 2 + assert FileVersion.objects.filter(url_path="reviews/review.pdf").count() == 2 + + +def test_file_versions_api_retrieves_specific_revision_by_query_param(): + client = _auth_client("user4@example.com") + client.post( + "/api/documents/reviews/review.pdf", + {"file": SimpleUploadedFile("review.pdf", b"first-version", content_type="application/pdf")}, + format="multipart", + ) + client.post( + "/api/documents/reviews/review.pdf", + {"file": SimpleUploadedFile("review.pdf", b"second-version", content_type="application/pdf")}, + format="multipart", + ) + + response = client.get("/api/documents/reviews/review.pdf?revision=0") + assert response.status_code == 200 + assert b"".join(response.streaming_content) == b"first-version" + + response = client.get("/api/documents/reviews/review.pdf?revision=1") + assert response.status_code == 200 + assert b"".join(response.streaming_content) == b"second-version" + + +def test_document_metadata_endpoint_returns_json_for_latest_and_revision(): + client = _auth_client("user5@example.com") + client.post( + "/api/documents/reviews/review.pdf", + {"file": SimpleUploadedFile("review.pdf", b"first-version", content_type="application/pdf")}, + format="multipart", + ) + client.post( + "/api/documents/reviews/review.pdf", + {"file": SimpleUploadedFile("review.pdf", b"second-version", content_type="application/pdf")}, + format="multipart", + ) + + latest = client.get("/api/documents/reviews/review.pdf/metadata") + assert latest.status_code == 200 + assert latest.data["url_path"] == "reviews/review.pdf" + assert latest.data["version_number"] == 2 + + revision_zero = client.get("/api/documents/reviews/review.pdf/metadata?revision=0") + assert revision_zero.status_code == 200 + assert revision_zero.data["version_number"] == 1 + + +def test_endpoints_require_authentication(): + client = APIClient() + response = client.post( + "/api/documents/reviews/review.pdf", + {"file": SimpleUploadedFile("review.pdf", b"content", content_type="application/pdf")}, + format="multipart", + ) + assert response.status_code in (401, 403) + + response = client.get("/api/documents/reviews/review.pdf") + assert response.status_code in (401, 403) + + response = client.get("/api/documents/reviews/review.pdf/metadata") + assert response.status_code in (401, 403) + + response = client.get("/api/file_versions/") + assert response.status_code in (401, 403) + + +def test_users_cannot_access_files_uploaded_by_others(): + owner_client = _auth_client("owner@example.com") + owner_client.post( + "/api/documents/reviews/review.pdf", + {"file": SimpleUploadedFile("review.pdf", b"owner-data", content_type="application/pdf")}, + format="multipart", + ) + + other_client = _auth_client("other@example.com") + response = other_client.get("/api/documents/reviews/review.pdf") + assert response.status_code == 404 + + metadata_response = other_client.get("/api/documents/reviews/review.pdf/metadata") + assert metadata_response.status_code == 404 + + list_response = other_client.get("/api/file_versions/") + assert list_response.status_code == 200 + assert list_response.data == [] + + +def test_different_users_can_use_same_url_with_independent_versioning(): + first_user = _auth_client("first@example.com") + second_user = _auth_client("second@example.com") + + first_upload = first_user.post( + "/api/documents/reviews/review.pdf", + {"file": SimpleUploadedFile("review.pdf", b"first-user-v1", content_type="application/pdf")}, + format="multipart", + ) + second_upload = second_user.post( + "/api/documents/reviews/review.pdf", + {"file": SimpleUploadedFile("review.pdf", b"second-user-v1", content_type="application/pdf")}, + format="multipart", + ) + + assert first_upload.status_code == 201 + assert second_upload.status_code == 201 + assert first_upload.data["version_number"] == 1 + assert second_upload.data["version_number"] == 1 + + +def test_cas_endpoint_returns_file_for_owner_by_hash(): + client = _auth_client("cas-owner@example.com") + upload = client.post( + "/api/documents/reviews/review.pdf", + {"file": SimpleUploadedFile("review.pdf", b"cas-data", content_type="application/pdf")}, + format="multipart", + ) + + assert upload.status_code == 201 + content_hash = upload.data["content_hash"] + assert content_hash + + response = client.get(f"/api/cas/{content_hash}") + assert response.status_code == 200 + assert b"".join(response.streaming_content) == b"cas-data" + + +def test_cas_endpoint_enforces_auth_and_owner_isolation(): + owner_client = _auth_client("cas-owner-2@example.com") + upload = owner_client.post( + "/api/documents/reviews/review.pdf", + {"file": SimpleUploadedFile("review.pdf", b"secret-cas-data", content_type="application/pdf")}, + format="multipart", + ) + content_hash = upload.data["content_hash"] + + unauth_client = APIClient() + unauth_response = unauth_client.get(f"/api/cas/{content_hash}") + assert unauth_response.status_code in (401, 403) + + other_client = _auth_client("cas-other@example.com") + other_response = other_client.get(f"/api/cas/{content_hash}") + assert other_response.status_code == 404