# Copyright © The Debusine Developers
# See the AUTHORS file at the top-level directory of this distribution
#
# This file is part of Debusine. It is subject to the license terms
# in the LICENSE file found in the top-level directory of this
# distribution. No part of Debusine, including this file, may be copied,
# modified, propagated, or distributed except according to the terms
# contained in the LICENSE file.

"""Tests for the views."""

import hashlib
import logging
import secrets
import shutil
from collections.abc import Callable
from datetime import datetime
from datetime import timezone as tz
from pathlib import Path
from typing import Any, ClassVar
from unittest import mock
from unittest.mock import MagicMock

from django.conf import settings
from django.db.models import Max
from django.http import HttpRequest
from django.http.response import HttpResponseBase
from django.test import override_settings
from django.urls import reverse
from django.utils import timezone
from django.utils.http import urlencode
from rest_framework import status
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.test import APIClient
from rest_framework.views import APIView

from debusine.artifacts.models import ArtifactCategory, CollectionCategory
from debusine.client.models import (
    LookupChildType,
    LookupResultType,
    model_to_json_serializable_dict,
)
from debusine.db.context import context
from debusine.db.models import (
    Artifact,
    ArtifactRelation,
    CollectionItem,
    DEFAULT_WORKSPACE_NAME,
    File,
    FileInArtifact,
    FileInStore,
    FileStore,
    FileUpload,
    Token,
    WorkRequest,
    Worker,
    WorkflowTemplate,
    Workspace,
    default_workspace,
)
from debusine.db.playground import scenarios
from debusine.db.tests.utils import _calculate_hash_from_data
from debusine.server.scopes import urlconf_scope
from debusine.server.serializers import (
    ArtifactSerializerResponse,
    WorkRequestSerializer,
)
from debusine.server.views.artifacts import UploadFileView
from debusine.server.views.base import (
    IsGet,
    IsTokenAuthenticated,
    IsTokenUserAuthenticated,
    IsWorkerAuthenticated,
)
from debusine.server.views.lookups import LookupMultipleView, LookupSingleView
from debusine.server.views.tests.test_work_requests import WorkRequestTestCase
from debusine.server.views.workers import (
    GetNextWorkRequestView,
    UpdateWorkRequestAsCompletedView,
    UpdateWorkerDynamicMetadataView,
)
from debusine.tasks.models import (
    LookupMultiple,
    OutputData,
    RuntimeStatistics,
    SbuildData,
    SbuildInput,
    TaskTypes,
    WorkerType,
)
from debusine.test.django import (
    JSONResponseProtocol,
    TestCase,
    TestResponseType,
)
from debusine.test.utils import data_generator


class RegisterViewTests(TestCase):
    """Tests for the RegisterView class."""

    def test_create_token_and_worker(self) -> None:
        """Token and Worker are created by the view."""
        self.assertQuerySetEqual(Token.objects.all(), [])
        self.assertQuerySetEqual(Worker.objects.all(), [])

        time_start = timezone.now()

        token_key = secrets.token_hex(32)

        data = {'token': token_key, 'fqdn': 'worker-bee.lan'}

        response = self.client.post(reverse('api:register'), data)

        self.assertEqual(response.status_code, status.HTTP_201_CREATED)

        token = Token.objects.get_token_or_none(token_key=token_key)
        assert token is not None

        worker = token.worker

        # Assert created worker
        self.assertEqual(worker.name, 'worker-bee-lan')
        self.assertGreaterEqual(worker.registered_at, time_start)
        self.assertLessEqual(worker.registered_at, timezone.now())
        self.assertIsNone(worker.connected_at)
        self.assertEqual(worker.worker_type, WorkerType.EXTERNAL)

        # Assert created token
        self.assertGreaterEqual(token.created_at, time_start)
        self.assertLessEqual(token.created_at, timezone.now())
        self.assertIsNone(token.user)
        self.assertEqual(token.comment, '')

    def test_create_token_and_worker_duplicate_name(self) -> None:
        """Token is created and Worker disambiguated if needed."""
        token_1 = Token()
        token_1.save()

        Worker.objects.create_with_fqdn('worker-lan', token_1)

        token_key = secrets.token_hex(32)
        data = {'token': token_key, 'fqdn': 'worker.lan'}
        response = self.client.post(reverse('api:register'), data)

        self.assertEqual(response.status_code, status.HTTP_201_CREATED)

        self.assertTrue(Worker.objects.filter(name='worker-lan-2').exists())

    def test_create_token_and_worker_signing(self) -> None:
        """Register a signing worker."""
        self.assertQuerySetEqual(Token.objects.all(), [])
        self.assertQuerySetEqual(Worker.objects.all(), [])

        time_start = timezone.now()

        token_key = secrets.token_hex(32)

        data = {
            'token': token_key,
            'fqdn': 'worker-bee.lan',
            'worker_type': WorkerType.SIGNING,
        }

        response = self.client.post(reverse('api:register'), data)

        self.assertEqual(response.status_code, status.HTTP_201_CREATED)

        token = Token.objects.get_token_or_none(token_key=token_key)
        assert token is not None

        worker = token.worker

        # Assert created worker
        self.assertEqual(worker.name, 'worker-bee-lan')
        self.assertGreaterEqual(worker.registered_at, time_start)
        self.assertLessEqual(worker.registered_at, timezone.now())
        self.assertIsNone(worker.connected_at)
        self.assertEqual(worker.worker_type, WorkerType.SIGNING)

        # Assert created token
        self.assertGreaterEqual(token.created_at, time_start)
        self.assertLessEqual(token.created_at, timezone.now())
        self.assertIsNone(token.user)
        self.assertEqual(token.comment, '')

    def test_invalid_data(self) -> None:
        """Request is refused if we have bad data."""
        data = {
            'token': secrets.token_hex(128),  # Too long
            'fqdn': 'worker.lan',
        }

        response = self.client.post(reverse('api:register'), data)

        self.assertResponseProblem(
            response,
            "Cannot deserialize worker",
            validation_errors_pattern=(
                r"Ensure this field has no more than .* characters"
            ),
        )
        self.assertIsInstance(response.json()["validation_errors"], dict)

        self.assertFalse(Worker.objects.filter(name='worker-lan').exists())


class GetNextWorkRequestViewTests(WorkRequestTestCase):
    """Tests for GetNextWorkRequestView."""

    token: ClassVar[Token]
    worker: ClassVar[Worker]

    @classmethod
    def setUpTestData(cls) -> None:
        """Set up common data."""
        super().setUpTestData()
        cls.token = cls.playground.create_bare_token()
        cls.worker = Worker.objects.create_with_fqdn("worker-test", cls.token)

    def create_work_request(
        self, status: WorkRequest.Statuses, created_at: datetime
    ) -> WorkRequest:
        """Return a new work_request as specified by the parameters."""
        environment_item = self.playground.create_debian_environment()
        assert environment_item.artifact is not None
        source_artifact = self.playground.create_source_artifact()
        task_data = SbuildData(
            input=SbuildInput(source_artifact=source_artifact.id),
            host_architecture="test",
            environment=environment_item.artifact.id,
        )

        work_request = self.playground.create_work_request(
            worker=self.worker,
            task_name="sbuild",
            task_data=task_data,
            status=status,
        )

        work_request.created_at = created_at

        work_request.save()

        return work_request

    def test_check_permissions(self) -> None:
        """Only authenticated requests are processed by the view."""
        self.assertIn(
            IsWorkerAuthenticated,
            GetNextWorkRequestView.permission_classes,
        )

    def test_get_running_work_request(self) -> None:
        """A running work request is aborted and retried."""
        # Create WorkRequest pending
        work_request_pending = self.create_work_request(
            WorkRequest.Statuses.PENDING,
            datetime(2022, 1, 5, 10, 13, 20, 204242, tz.utc),
        )

        # Create WorkRequest running
        work_request_running = self.create_work_request(
            WorkRequest.Statuses.RUNNING,
            datetime(2022, 1, 5, 11, 14, 22, 242178, tz.utc),
        )

        # Request
        response = self.client.get(
            reverse('api:work-request-get-next'),
            headers={"token": self.token.key},
        )

        # The running work request was retried, though not yet assigned to a
        # worker; we got the pending one.
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        work_request_running.refresh_from_db()
        self.assertEqual(
            work_request_running.status, WorkRequest.Statuses.ABORTED
        )
        self.assertTrue(hasattr(work_request_running, "superseded"))
        self.assertIsNone(work_request_running.superseded.worker)
        self.check_response_for_work_request(response, work_request_pending)

    def test_get_running_work_request_cannot_retry(self) -> None:
        """If a running work request cannot be retried, it is logged."""
        work_request_pending = self.create_work_request(
            WorkRequest.Statuses.PENDING,
            datetime(2022, 1, 5, 10, 13, 20, 204242, tz.utc),
        )
        work_request_running = self.create_work_request(
            WorkRequest.Statuses.RUNNING,
            datetime(2022, 1, 5, 11, 14, 22, 242178, tz.utc),
        )
        work_request_running.task_data = {"invalid": True}
        work_request_running.save()

        with self.assertLogsContains(
            "Cannot retry previously-running work request: "
            "Task dependencies cannot be satisfied",
            logger="debusine.server.views.workers",
            level=logging.DEBUG,
        ):
            response = self.client.get(
                reverse('api:work-request-get-next'),
                headers={"token": self.token.key},
            )

        # The running work request was aborted but could not be retried; we
        # got the pending one.
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        work_request_running.refresh_from_db()
        self.assertEqual(
            work_request_running.status, WorkRequest.Statuses.ABORTED
        )
        self.assertFalse(hasattr(work_request_running, "superseded"))
        self.check_response_for_work_request(response, work_request_pending)

    def test_get_work_request_change_status(self) -> None:
        """Get a WorkRequest change the status to running."""
        work_request = self.create_work_request(
            WorkRequest.Statuses.PENDING,
            datetime(2022, 1, 5, 10, 13, 20, 204242, tz.utc),
        )

        # Request
        response = self.client.get(
            reverse('api:work-request-get-next'),
            headers={"token": self.token.key},
        )

        # Assert that we got the WorkRequest running
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.check_response_for_work_request(response, work_request)

        work_request.refresh_from_db()

        self.assertEqual(work_request.status, WorkRequest.Statuses.RUNNING)

    def test_get_older_pending(self) -> None:
        """View return the older pending work request."""
        # Create WorkRequest pending
        work_request_pending_01 = self.create_work_request(
            WorkRequest.Statuses.PENDING,
            datetime(2022, 1, 5, 10, 13, 20, 204242, tz.utc),
        )

        work_request_pending_02 = self.create_work_request(
            WorkRequest.Statuses.PENDING,
            datetime(2022, 1, 5, 10, 13, 22, 204242, tz.utc),
        )
        # Request
        response = self.client.get(
            reverse('api:work-request-get-next'),
            headers={"token": self.token.key},
        )

        self.check_response_for_work_request(response, work_request_pending_01)

        # work_request_pending_01 status changed. Let's move it back to
        # pending. This is not an allowed transition but helps here to test
        # that clients will receive the older pending
        work_request_pending_01.status = WorkRequest.Statuses.PENDING

        # Change created_at to force a change in the order that debusine
        # sends the WorkRequests to the client
        work_request_pending_01.created_at = datetime(
            2022, 1, 5, 10, 13, 24, 204242, tz.utc
        )

        work_request_pending_01.save()

        # New request to check the new order
        response = self.client.get(
            reverse('api:work-request-get-next'),
            headers={"token": self.token.key},
        )

        self.check_response_for_work_request(response, work_request_pending_02)

    def test_get_no_work_request(self) -> None:
        """Assert debusine sends HTTP 204 when nothing assigned to Worker."""
        response = self.client.get(
            reverse('api:work-request-get-next'),
            headers={"token": self.token.key},
        )

        self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)

    def test_authentication_credentials_not_provided(self) -> None:
        """Assert Token is required to use the endpoint."""
        # This is to double-check that IsWorkerAuthenticated (inclusion
        # is verified in test_check_permissions) is returning what it should do.
        # All the views with IsWorkerAuthenticated behave the same way
        # IsWorkerAuthenticated is tested in IsWorkerAuthenticatedTests
        response = self.client.get(reverse('api:work-request-get-next'))
        self.assertEqual(
            response.json(),
            {
                "title": "Error",
                "detail": "Authentication credentials were not provided.",
            },
        )
        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)


class UpdateWorkRequestAsCompletedTests(TestCase):
    """Tests for UpdateWorkRequestAsCompleted class."""

    token: ClassVar[Token]
    worker: ClassVar[Worker]
    work_request: ClassVar[WorkRequest]

    @classmethod
    def setUpTestData(cls) -> None:
        """Set up common data."""
        super().setUpTestData()
        cls.token = cls.playground.create_bare_token()
        cls.worker = Worker.objects.create_with_fqdn("worker-test", cls.token)

        environment_item = cls.playground.create_debian_environment()
        assert environment_item.artifact is not None
        source_artifact = cls.playground.create_source_artifact()
        task_data = SbuildData(
            input=SbuildInput(source_artifact=source_artifact.id),
            host_architecture="test",
            environment=environment_item.artifact.id,
        )

        cls.work_request = cls.playground.create_work_request(
            worker=cls.worker,
            task_name='sbuild',
            task_data=task_data,
            status=WorkRequest.Statuses.PENDING,
        )

    def test_check_permissions(self) -> None:
        """Only authenticated requests are processed by the view."""
        self.assertIn(
            IsWorkerAuthenticated,
            UpdateWorkRequestAsCompletedView.permission_classes,
        )

    def put_api_work_request_completed(
        self, result: WorkRequest.Results, output_data: OutputData | None = None
    ) -> None:
        """Assert Worker can update WorkRequest result to completed-success."""
        self.work_request.status = WorkRequest.Statuses.RUNNING
        self.work_request.save()

        data: dict[str, Any] = {"result": result}
        if output_data is not None:
            data["output_data"] = model_to_json_serializable_dict(
                output_data, exclude_unset=True
            )
        response = self.client.put(
            reverse(
                'api:work-request-completed',
                kwargs={'work_request_id': self.work_request.id},
            ),
            data=data,
            headers={"token": self.token.key},
            content_type="application/json",
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)

        self.work_request.refresh_from_db()
        self.assertEqual(
            self.work_request.status, WorkRequest.Statuses.COMPLETED
        )
        self.assertEqual(self.work_request.result, result)
        self.assertEqual(self.work_request.output_data, output_data)

    def test_update_completed_result_is_success(self) -> None:
        """Assert Worker can update WorkRequest result to completed-success."""
        self.put_api_work_request_completed(WorkRequest.Results.SUCCESS)

    def test_update_completed_result_is_success_with_output_data(self) -> None:
        """Assert Worker can update WorkRequest result with output data."""
        self.put_api_work_request_completed(
            WorkRequest.Results.SUCCESS,
            OutputData(runtime_statistics=RuntimeStatistics(duration=60)),
        )

    def test_update_completed_result_is_failure(self) -> None:
        """Assert Worker can update WorkRequest result to completed-error."""
        self.put_api_work_request_completed(WorkRequest.Results.ERROR)

    def test_update_work_request_id_not_found(self) -> None:
        """Assert API returns HTTP 404 for a non-existing WorkRequest id."""
        max_id = WorkRequest.objects.aggregate(Max('id'))['id__max']

        response = self.client.put(
            reverse(
                'api:work-request-completed',
                kwargs={'work_request_id': max_id + 1},
            ),
            data={"result": WorkRequest.Results.SUCCESS},
            headers={"token": self.token.key},
            content_type="application/json",
        )

        self.assertResponseProblem(
            response,
            "Work request not found",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_update_failure_invalid_result(self) -> None:
        """Assert Worker get HTTP 400 and error for invalid result field."""
        self.work_request.status = WorkRequest.Statuses.RUNNING
        self.work_request.save()

        response = self.client.put(
            reverse(
                'api:work-request-completed',
                kwargs={'work_request_id': self.work_request.id},
            ),
            data={"result": "something not recognised"},
            headers={"token": self.token.key},
            content_type="application/json",
        )

        self.assertResponseProblem(
            response,
            "Cannot change work request as completed",
            validation_errors_pattern="something not recognised",
        )

    def test_unauthorized(self) -> None:
        """Assert worker cannot modify a task of another worker."""
        another_worker = Worker.objects.create_with_fqdn(
            "another-worker-test", self.playground.create_bare_token()
        )

        environment_item = self.playground.create_debian_environment()
        assert environment_item.artifact is not None
        source_artifact = self.playground.create_source_artifact()
        task_data = SbuildData(
            input=SbuildInput(source_artifact=source_artifact.id),
            host_architecture="test",
            environment=environment_item.artifact.id,
        )

        another_work_request = self.playground.create_work_request(
            worker=another_worker,
            task_name='sbuild',
            task_data=task_data,
            status=WorkRequest.Statuses.PENDING,
        )

        response = self.client.put(
            reverse(
                'api:work-request-completed',
                kwargs={'work_request_id': another_work_request.id},
            ),
            headers={"token": self.token.key},
        )

        self.assertResponseProblem(
            response,
            "Invalid worker to update the work request",
            status_code=status.HTTP_401_UNAUTHORIZED,
        )


class UpdateWorkerDynamicMetadataTests(TestCase):
    """Tests for DynamicMetadata."""

    @context.disable_permission_checks()
    def setUp(self) -> None:
        """Set up common objects."""
        self.worker_01 = Worker.objects.create_with_fqdn(
            'worker-01-lan', self.playground.create_bare_token()
        )
        self.worker_02 = Worker.objects.create_with_fqdn(
            'worker-02-lan', self.playground.create_bare_token()
        )

    def test_check_permissions(self) -> None:
        """Only authenticated requests are processed by the view."""
        self.assertIn(
            IsWorkerAuthenticated,
            UpdateWorkerDynamicMetadataView.permission_classes,
        )

    def test_update_metadata_success(self) -> None:
        """Worker's dynamic_metadata is updated."""
        assert self.worker_01.token is not None
        self.assertEqual(self.worker_01.dynamic_metadata, {})
        self.assertEqual(self.worker_02.dynamic_metadata, {})

        metadata = {"cpu_cores": 4, "ram": 16}
        response = self.client.put(
            reverse('api:worker-dynamic-metadata'),
            data=metadata,
            headers={"token": self.worker_01.token.key},
            content_type="application/json",
        )

        self.worker_01.refresh_from_db()

        self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)

        self.assertEqual(self.worker_01.dynamic_metadata, metadata)
        assert self.worker_01.dynamic_metadata_updated_at is not None
        self.assertLessEqual(
            self.worker_01.dynamic_metadata_updated_at, timezone.now()
        )

        self.assertEqual(self.worker_02.dynamic_metadata, {})
        self.assertIsNone(self.worker_02.dynamic_metadata_updated_at)


class ArtifactViewTests(TestCase):
    """Tests for ArtifactView."""

    scenario = scenarios.DefaultContextAPI()
    playground_memory_file_store = False

    default_workspace_name: ClassVar[str]
    workspace: ClassVar[Workspace]

    @classmethod
    @context.disable_permission_checks()
    def setUpTestData(cls) -> None:
        """Set up common data for tests."""
        super().setUpTestData()
        cls.default_workspace_name = "test"
        cls.workspace = cls.playground.create_workspace(
            name=cls.default_workspace_name, public=True
        )
        cls.playground.create_group_role(
            cls.workspace, Workspace.Roles.OWNER, cls.scenario.user
        )

    @staticmethod
    def empty_store_directory() -> None:
        """Empty settings.DEBUSINE_STORE_DIRECTORY."""
        shutil.rmtree(settings.DEBUSINE_STORE_DIRECTORY, ignore_errors=True)
        Path(settings.DEBUSINE_STORE_DIRECTORY).mkdir()

    @staticmethod
    def create_file_object(hash_digest: bytes, size: int) -> File:
        """Create new file with hash_digest and size."""
        fileobj = File()
        fileobj.hash_digest = hash_digest
        fileobj.size = size
        fileobj.save()

        store = FileStore.default()

        FileInStore.objects.create(file=fileobj, store=store, data={})

        return fileobj

    def post_artifact_create(
        self,
        *,
        token_key: str,
        data: dict[str, Any],
        hostname: str | None = None,
        scope: str | None = None,
    ) -> TestResponseType:
        """
        Call self.client.post() to api-artifact-create.

        Pass the token.key and content_type="application/json".

        :param token_key: value of the Token header for the request
        :param data: data to post
        :param hostname: if not None, use it for HTTP_HOST
        """
        headers: dict[str, str] = {}
        if hostname is not None:
            headers["Host"] = hostname
        if scope is not None:
            headers["X-Debusine-Scope"] = scope

        return self.client.post(
            reverse("api:artifact-create"),
            data=data,
            HTTP_TOKEN=token_key,
            content_type="application/json",
            headers=headers,
        )

    @override_settings(ALLOWED_HOSTS=["*"])
    def test_create_artifact(self) -> None:
        """An artifact is created."""
        # file_already_uploaded_* is in the File and Store tables. The file
        # was already created and uploaded

        # Test will ensure that the empty file is created in the store.
        # Empty the store directory in case that the file existed from
        # a previous test
        self.empty_store_directory()

        file_already_uploaded_path = "AUTHORS"
        file_already_uploaded_contents = b"contents of AUTHORS"
        self.create_file_object(
            _calculate_hash_from_data(file_already_uploaded_contents),
            len(file_already_uploaded_contents),
        )

        # file_exists_not_uploaded_* exists in the File table but is not in
        # the Store. The client should upload it (we do not know if another
        # client would upload it or not)
        file_exists_not_uploaded_path = "src/dd.c"
        file_exists_not_uploaded_contents = b"contents of src/dd.c"
        self.playground.create_file(file_exists_not_uploaded_contents)

        path_in_artifact_empty_file = "src/.dirstamp"
        category = "testing"
        artifact_serialized = {
            "workspace": self.default_workspace_name,
            "category": category,
            "files": {
                file_already_uploaded_path: {
                    "type": "file",
                    "size": len(file_already_uploaded_contents),
                    "checksums": {
                        "sha256": hashlib.sha256(
                            file_already_uploaded_contents
                        ).hexdigest(),
                        "md5": hashlib.md5(
                            file_already_uploaded_contents
                        ).hexdigest(),
                    },
                },
                file_exists_not_uploaded_path: {
                    "type": "file",
                    "size": len(file_exists_not_uploaded_contents),
                    "checksums": {
                        "sha256": hashlib.sha256(
                            file_exists_not_uploaded_contents
                        ).hexdigest(),
                        "md5": hashlib.md5(
                            file_exists_not_uploaded_contents
                        ).hexdigest(),
                    },
                },
                "src/seq.c": {
                    "type": "file",
                    "size": 21750,
                    "checksums": {
                        "sha256": (
                            "acd6454390756911a464b426dcc49027055c"
                            "ddf9258112dfd7a52ee96e231352"
                        ),
                        "md5": "e13c3d5d2489a7ef6b697379f74c2fd4",
                    },
                },
                path_in_artifact_empty_file: {
                    "type": "file",
                    "size": 0,
                    "checksums": {
                        "sha256": hashlib.sha256().hexdigest(),
                        "md5": hashlib.md5().hexdigest(),
                    },
                },
            },
            "data": {"key1": "value1"},
        }
        self.assertEqual(Artifact.objects.all().count(), 0)

        hostname = "example.com"

        response = self.post_artifact_create(
            token_key=self.scenario.user_token.key,
            data=artifact_serialized,
            hostname=hostname,
        )

        self.assertEqual(response.status_code, status.HTTP_201_CREATED)

        response_json = response.json()
        artifact = Artifact.objects.get(id=response_json["id"])

        # Assert created artifact is as expected
        self.assertEqual(artifact.category, artifact_serialized["category"])
        self.assertEqual(
            artifact.workspace.name, artifact_serialized["workspace"]
        )
        self.assertEqual(artifact.data, artifact_serialized["data"])
        self.assertIsNone(artifact.created_by_work_request)

        # expected_files_to_upload:
        # "AUTHORS" file was already available in the debusine server, it does
        #   not need to be uploaded again.
        # "src/dd.c" file existed in the File table but not in FileInStorage.
        #   It might be uploaded by another client or not: so debusine
        #   server requests this client to upload it.
        # "src/seq.c" is a 100% new file in debusine.
        # "src/.dirstamp" is an empty file: debusine does not request
        #   to be uploaded again.

        self.assertEqual(
            response_json,
            self.artifact_as_dict_response(artifact, hostname),
        )

        self.assertEqual(
            artifact.files.all().count(), len(artifact_serialized["files"])
        )

        file_backend = FileStore.default().get_backend_object()

        # Assert that files have the correct size and hash_digest
        assert isinstance(artifact_serialized["files"], dict)
        for path, file_data in artifact_serialized["files"].items():
            file_in_artifact = FileInArtifact.objects.get(
                artifact=artifact, path=path
            )
            size = file_in_artifact.file.size
            self.assertEqual(size, file_data["size"])
            self.assertEqual(
                file_in_artifact.file.hash_digest.hex(),
                file_data["checksums"]["sha256"],
            )

            local_path = file_backend.get_local_path(file_in_artifact.file)
            assert local_path is not None
            if size == 0:
                # Empty files are created, and the FileInArtifact is complete
                empty_file_stat = local_path.stat()
                self.assertEqual(empty_file_stat.st_size, 0)
                self.assertTrue(file_in_artifact.complete)
            else:
                # Non-empty files are not created yet, and the
                # FileInArtifact is incomplete
                self.assertFalse(local_path.exists())
                self.assertFalse(file_in_artifact.complete)

        # Assert that ArtifactView.post() created an empty file for the
        # empty file. Other files would be created when the client makes
        # the PUT request
        file_upload = FileUpload.objects.get(
            file_in_artifact__path=path_in_artifact_empty_file
        )
        path_empty_file = Path(file_upload.absolute_file_path())
        self.assertTrue(path_empty_file.is_file())
        self.assertEqual(path_empty_file.stat().st_size, 0)

        with self.captureOnCommitCallbacks(execute=True):
            # Delete FileUpload to delete the associated file in
            # DEBUSINE_UPLOAD_DIRECTORY. Only for the empty file because
            # ArtifactView.post() created the file only for the empty one
            file_upload.delete()

    @staticmethod
    def artifact_as_dict_response(
        artifact: Artifact, hostname: str
    ) -> dict[str, Any]:
        """Return dict: server return to the client."""
        path = artifact.get_absolute_url_download()

        with mock.patch.object(
            ArtifactSerializerResponse,
            "_build_absolute_download_url",
            autospec=True,
            return_value=f"http://{hostname}{path}",
        ):
            unused_request = MagicMock()
            serialized = ArtifactSerializerResponse.from_artifact(
                artifact, unused_request
            ).data
            serialized["download_tar_gz_url"] = (
                f"http://{hostname}{path}?archive=tar.gz"
            )
            return serialized

    def artifact_as_dict_request(self, work_request_id: int) -> dict[str, Any]:
        """Return dict: client post to the server to create an artifact."""
        return {
            "workspace": self.default_workspace_name,
            "category": "testing",
            "files": {},
            "work_request": work_request_id,
            "data": {},
        }

    @override_settings(DISABLE_AUTOMATIC_SCHEDULING=True, ALLOWED_HOSTS=["*"])
    def test_create_artifact_created_by_work_request(self) -> None:
        """Request include a work request id. Artifact get created with it."""
        work_request = self.playground.create_work_request()
        token = self.playground.create_worker_token()

        response = self.post_artifact_create(
            token_key=token.key,
            data=self.artifact_as_dict_request(work_request.id),
            hostname="example.com",
        )

        artifact = Artifact.objects.get(id=response.json()["id"])
        self.assertEqual(artifact.created_by_work_request, work_request)

    def test_create_artifact_created_by_work_request_no_permissions(
        self,
    ) -> None:
        """
        Request is client authenticated (not worker). Invalid permission.

        Only workers can set the Artifact.created_by_work_request().
        """
        work_request = self.playground.create_work_request()

        response = self.post_artifact_create(
            token_key=self.scenario.user_token.key,
            data=self.artifact_as_dict_request(work_request.id),
        )

        self.assertResponseProblem(
            response,
            "work_request field can only be used by a request "
            "with a token from a Worker",
            status_code=status.HTTP_403_FORBIDDEN,
        )

        # No artifact is created
        self.assertEqual(Artifact.objects.count(), 0)

    def test_create_artifact_created_by_work_request_not_found(self) -> None:
        """
        Request include a non-existing work request id.

        Assert that the view return HTTP 400 error.
        """
        # Associated a Worker to the token
        work_request_id = -1

        response = self.post_artifact_create(
            token_key=self.playground.create_worker_token().key,
            data=self.artifact_as_dict_request(work_request_id),
        )
        self.assertResponseProblem(
            response, f"WorkRequest {work_request_id} does not exist"
        )

    @override_settings(ALLOWED_HOSTS=["*"])
    def test_create_artifact_no_work_request(self) -> None:
        """Assert artifact is created without a work request relation."""
        artifact_serialized = {
            "workspace": self.default_workspace_name,
            "category": "testing",
            "files": {},
            "data": {},
        }

        token = self.scenario.user_token

        response = self.post_artifact_create(
            token_key=token.key,
            data=artifact_serialized,
            hostname="example.com",
        )

        self.assertEqual(response.status_code, status.HTTP_201_CREATED)

        assert isinstance(response, Response)
        artifact = Artifact.objects.get(id=response.data["id"])
        self.assertIsNone(artifact.created_by_work_request)
        self.assertEqual(artifact.created_by, token.user)

    @override_settings(ALLOWED_HOSTS=["*"])
    def test_create_artifact_fails_no_work_request_id_no_user_token(
        self,
    ) -> None:
        """
        Artifact cannot be created.

        The data does not contain a work_request_id and the token is not
        associated to a user.
        """
        artifact_serialized = {
            "workspace": self.default_workspace_name,
            "category": "testing",
            "files": {},
            "data": {},
        }

        response = self.post_artifact_create(
            token_key=self.playground.create_bare_token().key,
            data=artifact_serialized,
            hostname="example.com",
        )

        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

        self.assertEqual(
            response.json(),
            {
                "title": "AnonymousUser cannot create artifacts"
                " in debusine/test",
            },
        )

    @override_settings(ALLOWED_HOSTS=["*"])
    def test_create_artifact_fails_no_work_request_id_and_worker_token(
        self,
    ) -> None:
        """
        Artifact cannot be created.

        The data does not contain a work_request_id but a worker token is used.
        """
        artifact_serialized = {
            "workspace": self.default_workspace_name,
            "category": "testing",
            "files": {},
            "data": {},
        }

        response = self.post_artifact_create(
            token_key=self.playground.create_worker_token().key,
            data=artifact_serialized,
            hostname="example.com",
        )

        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

        self.assertEqual(
            response.json(),
            {
                'title': "If work_request field is empty the WorkRequest"
                " must be created using a Token associated to a user"
            },
        )

    @override_settings(ALLOWED_HOSTS=["*"])
    def test_create_artifact_no_workspace(self) -> None:
        """Assert artifact is serialized in the default workspace."""
        artifact_serialized = {
            "category": "testing",
            "files": {},
            "data": {},
        }

        response = self.post_artifact_create(
            token_key=self.scenario.user_token.key,
            data=artifact_serialized,
            hostname="example.com",
        )

        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        assert isinstance(response, Response)
        artifact = Artifact.objects.get(id=response.data["id"])
        self.assertEqual(artifact.workspace.name, DEFAULT_WORKSPACE_NAME)

    def test_create_artifact_when_workspace_does_not_exist(self) -> None:
        """No artifact is created: workspace not found."""
        workspace_name = "does-not-exist"

        artifact_serialized = {
            "workspace": "does-not-exist",
            "category": "testing",
            "files": {},
            "data": {},
        }

        response = self.post_artifact_create(
            token_key=self.scenario.user_token.key,
            data=artifact_serialized,
        )

        self.assertResponseProblem(
            response,
            "Workspace not found",
            detail_pattern=f"Workspace {workspace_name} not found"
            " in scope debusine",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_create_artifact_deserialize_error(self) -> None:
        """Send invalid artifact (missing fields)."""
        artifact_serialized: dict[str, Any] = {}

        response = self.post_artifact_create(
            token_key=self.scenario.user_token.key,
            data=artifact_serialized,
        )

        self.assertResponseProblem(
            response,
            "Cannot deserialize artifact",
            validation_errors_pattern="This field is required",
        )

    @override_settings(ALLOWED_HOSTS=["*"])
    def test_create_artifact_do_not_reuse_invisible_files(self) -> None:
        """Files not in this workspace must be reuploaded."""
        with context.disable_permission_checks():
            other_workspace = self.playground.create_workspace(name="other")
            other_artifact, other_files_contents = (
                self.playground.create_artifact(
                    paths=["foo"], workspace=other_workspace, create_files=True
                )
            )
        artifact_serialized = {
            "workspace": self.default_workspace_name,
            "category": ArtifactCategory.TEST,
            "files": {
                "foo": {
                    "type": "file",
                    "size": len(other_files_contents["foo"]),
                    "checksums": {
                        "sha256": hashlib.sha256(
                            other_files_contents["foo"]
                        ).hexdigest()
                    },
                }
            },
            "data": {},
        }

        response = self.post_artifact_create(
            token_key=self.scenario.user_token.key,
            data=artifact_serialized,
            hostname="example.com",
        )

        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        response_json = response.json()
        self.assertEqual(response_json["files_to_upload"], ["foo"])
        file_in_artifact = FileInArtifact.objects.get(
            artifact=response_json["id"], path="foo"
        )
        self.assertFalse(file_in_artifact.complete)

    @override_settings(ALLOWED_HOSTS=["*"])
    def test_create_artifact_reuse_visible_files(self) -> None:
        """Files in this workspace do not need to be reuploaded."""
        with context.disable_permission_checks():
            artifact, files_contents = self.playground.create_artifact(
                paths=["foo"], workspace=self.workspace, create_files=True
            )
        artifact_serialized = {
            "workspace": self.default_workspace_name,
            "category": ArtifactCategory.TEST,
            "files": {
                "foo": {
                    "type": "file",
                    "size": len(files_contents["foo"]),
                    "checksums": {
                        "sha256": hashlib.sha256(
                            files_contents["foo"]
                        ).hexdigest()
                    },
                }
            },
            "data": {},
        }

        response = self.post_artifact_create(
            token_key=self.scenario.user_token.key,
            data=artifact_serialized,
            hostname="example.com",
        )

        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        response_json = response.json()
        self.assertEqual(response_json["files_to_upload"], [])
        file_in_artifact = FileInArtifact.objects.get(
            artifact=response_json["id"], path="foo"
        )
        self.assertTrue(file_in_artifact.complete)

    @override_settings(ALLOWED_HOSTS=["*"])
    def test_create_artifact_honours_scope(self) -> None:
        """POST checks that the workspace is in the current scope."""
        scope1 = self.playground.get_or_create_scope("scope1")
        scope2 = self.playground.get_or_create_scope("scope2")
        scope3 = self.playground.get_or_create_scope("scope3")
        workspace1 = self.playground.create_workspace(
            scope=scope1, name="common-name", public=True
        )
        workspace2 = self.playground.create_workspace(
            scope=scope2, name="common-name", public=True
        )
        artifact_serialized = {
            "workspace": "common-name",
            "category": "testing",
            "files": {},
            "data": {},
        }

        for workspace in (workspace1, workspace2):
            response = self.post_artifact_create(
                token_key=self.scenario.user_token.key,
                data=artifact_serialized,
                hostname="example.com",
                scope=workspace.scope.name,
            )

            self.assertEqual(response.status_code, status.HTTP_201_CREATED)
            assert isinstance(response, Response)
            artifact = Artifact.objects.get(id=response.data["id"])
            self.assertEqual(artifact.workspace, workspace)

        response = self.post_artifact_create(
            token_key=self.scenario.user_token.key,
            data=artifact_serialized,
            hostname="example.com",
            scope=scope3.name,
        )

        self.assertResponseProblem(
            response,
            "Workspace not found",
            detail_pattern="Workspace common-name not found in scope scope3",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    @override_settings(ALLOWED_HOSTS=["*"])
    def test_create_artifact_no_default_workspace(self) -> None:
        """POST with no workspace in a scope without a default workspace."""
        scope = self.playground.get_or_create_scope("empty-scope")
        artifact_serialized = {
            "category": "testing",
            "files": {},
            "data": {},
        }

        response = self.post_artifact_create(
            token_key=self.scenario.user_token.key,
            data=artifact_serialized,
            hostname="example.com",
            scope=scope.name,
        )

        self.assertResponseProblem(
            response,
            "Cannot deserialize artifact",
            validation_errors_pattern=(
                r"'workspace': \['This field is required\.'\]"
            ),
        )

    @override_settings(ALLOWED_HOSTS=["*"])
    def test_create_artifact_private_workspace_unauthorized(self) -> None:
        """Artifacts in private workspaces 404 to the unauthorized."""
        private_workspace = self.playground.create_workspace(name="Private")
        artifact_serialized = {
            "workspace": private_workspace.name,
            "category": "testing",
            "files": {},
            "data": {},
        }

        response = self.post_artifact_create(
            token_key=self.scenario.user_token.key,
            data=artifact_serialized,
            hostname="example.com",
            scope=private_workspace.scope.name,
        )

        self.assertResponseProblem(
            response,
            "Workspace not found",
            detail_pattern=(
                f"Workspace {private_workspace.name} not found in scope "
                f"{private_workspace.scope.name}"
            ),
            status_code=status.HTTP_404_NOT_FOUND,
        )

    @override_settings(ALLOWED_HOSTS=["*"])
    def test_create_artifact_private_workspace_authorized(self) -> None:
        """Artifacts in private workspaces 200 to the authorized."""
        private_workspace = self.playground.create_workspace(name="Private")
        self.playground.create_group_role(
            private_workspace, Workspace.Roles.OWNER, self.scenario.user
        )
        artifact_serialized = {
            "workspace": private_workspace.name,
            "category": "testing",
            "files": {},
            "data": {},
        }

        response = self.post_artifact_create(
            token_key=self.scenario.user_token.key,
            data=artifact_serialized,
            hostname="example.com",
            scope=private_workspace.scope.name,
        )

        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        assert isinstance(response, Response)
        artifact = Artifact.objects.get(id=response.data["id"])
        self.assertEqual(artifact.workspace, private_workspace)

    @override_settings(ALLOWED_HOSTS=["*"])
    def test_get_information(self) -> None:
        """Get information for the artifact."""
        with context.disable_permission_checks():
            artifact, _ = self.playground.create_artifact()
            token_key = self.scenario.user_token.key

            # Add a file (without "uploading" it) to test that files_to_upload
            # returns it.
            fileobj = self.playground.create_file()
            path_in_artifact = "README.txt"
            FileInArtifact.objects.create(
                artifact=artifact, file=fileobj, path=path_in_artifact
            )

        hostname = "example.com"
        response = self.client.get(
            reverse("api:artifact", kwargs={"artifact_id": artifact.id}),
            headers={"token": token_key, "host": hostname},
        )
        # HTTP_HOST must be a real hostname. By default, it is "testserver",
        # The Serializer needs a valid hostname for the URLField: otherwise
        # it fails the validation on the serializer
        # (for the download_artifact_tar_gz_url).
        # URLField can be an ipv4 or ipv6 address, a domain
        # or localhost.
        self.assertEqual(response.status_code, status.HTTP_200_OK)

        expected = self.artifact_as_dict_response(artifact, hostname)

        assert isinstance(response, Response)
        self.assertEqual(response.data, expected)

    def test_get_information_artifact_not_found(self) -> None:
        """Get information return 404: artifact not found."""
        artifact_id = 0
        response = self.client.get(
            reverse("api:artifact", kwargs={"artifact_id": artifact_id}),
            headers={"token": self.scenario.user_token.key},
        )

        self.assertResponseProblem(
            response,
            "No Artifact matches the given query.",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    @override_settings(ALLOWED_HOSTS=["*"])
    def test_get_information_honours_scope(self) -> None:
        """Getting artifact information looks it up in the current scope."""
        scope1 = self.playground.get_or_create_scope("scope1")
        scope2 = self.playground.get_or_create_scope("scope2")
        workspace = self.playground.create_workspace(
            scope=scope1, name="workspace", public=True
        )
        artifact, _ = self.playground.create_artifact(workspace=workspace)

        hostname = "example.com"
        response = self.client.get(
            reverse("api:artifact", kwargs={"artifact_id": artifact.id}),
            headers={
                "Host": hostname,
                "Token": self.scenario.user_token.key,
                "X-Debusine-Scope": artifact.workspace.scope.name,
            },
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        with urlconf_scope(artifact.workspace.scope.name):
            expected = self.artifact_as_dict_response(artifact, hostname)
        assert isinstance(response, Response)
        self.assertEqual(response.data, expected)

        response = self.client.get(
            reverse("api:artifact", kwargs={"artifact_id": artifact.id}),
            headers={
                "Host": hostname,
                "Token": self.scenario.user_token.key,
                "X-Debusine-Scope": scope2.name,
            },
        )

        self.assertResponseProblem(
            response,
            "No Artifact matches the given query.",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    @override_settings(ALLOWED_HOSTS=["*"])
    def test_get_information_private_workspace_unauthorized(self) -> None:
        """Artifacts in private workspaces 404 to the unauthorized."""
        private_workspace = self.playground.create_workspace(name="Private")
        artifact, _ = self.playground.create_artifact(
            workspace=private_workspace
        )

        hostname = "example.com"
        response = self.client.get(
            reverse("api:artifact", kwargs={"artifact_id": artifact.id}),
            headers={
                "Host": hostname,
                "Token": self.scenario.user_token.key,
                "X-Debusine-Scope": private_workspace.scope.name,
            },
        )

        self.assertResponseProblem(
            response,
            "No Artifact matches the given query.",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    @override_settings(ALLOWED_HOSTS=["*"])
    def test_get_information_private_workspace_authorized(self) -> None:
        """Artifacts in private workspaces 200 to the authorized."""
        private_workspace = self.playground.create_workspace(name="Private")
        self.playground.create_group_role(
            private_workspace, Workspace.Roles.OWNER, self.scenario.user
        )
        artifact, _ = self.playground.create_artifact(
            workspace=private_workspace
        )

        hostname = "example.com"
        response = self.client.get(
            reverse("api:artifact", kwargs={"artifact_id": artifact.id}),
            headers={
                "Host": hostname,
                "Token": self.scenario.user_token.key,
                "X-Debusine-Scope": private_workspace.scope.name,
            },
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        with urlconf_scope(artifact.workspace.scope.name):
            expected = self.artifact_as_dict_response(artifact, hostname)
        assert isinstance(response, Response)
        self.assertEqual(response.data, expected)


class IsWorkerAuthenticatedTests(TestCase):
    """Tests for IsWorkerAuthenticated."""

    def setUp(self) -> None:
        """Set up common objects."""
        self.is_worker_authenticated = IsWorkerAuthenticated()
        self.request = Request(HttpRequest())

    def test_request_with_valid_token_worker_yes_permission(self) -> None:
        """IsWorkerAuthenticated.has_permission() return True: valid token."""
        self.request.auth = self.playground.create_worker_token()
        self.assertTrue(
            self.is_worker_authenticated.has_permission(self.request, APIView())
        )

    def test_request_with_non_existing_token_no_permission(self) -> None:
        """
        IsWorkerAuthenticated.has_permission() return False.

        request.auth is None.
        """
        self.request.auth = None
        self.assertFalse(
            self.is_worker_authenticated.has_permission(self.request, APIView())
        )

    def test_request_with_token_no_associated_worker_no_permission(
        self,
    ) -> None:
        """IsWorkerAuthenticated.has_permission() return False: no worker."""
        self.request.auth = self.playground.create_bare_token()
        self.assertFalse(
            self.is_worker_authenticated.has_permission(self.request, APIView())
        )


class IsTokenAuthenticatedTests(TestCase):
    """Tests for IsTokenAuthenticated."""

    def setUp(self) -> None:
        """Set up common objects."""
        self.is_token_authenticated = IsTokenAuthenticated()
        self.request = Request(HttpRequest())

    def test_request_with_valid_token_worker_yes_permission(self) -> None:
        """IsTokenAuthenticated.has_permission() return True: valid token."""
        self.request.auth = self.playground.create_worker_token()
        self.assertTrue(
            self.is_token_authenticated.has_permission(self.request, APIView())
        )

    def test_request_with_non_existing_token_no_permission(self) -> None:
        """IsTokenAuthenticated.has_permission() return False: invalid token."""
        self.request.auth = None
        self.assertFalse(
            self.is_token_authenticated.has_permission(self.request, APIView())
        )

    def test_request_with_disabled_token_no_permission(self) -> None:
        """
        IsTokenAuthenticated.has_permission() return False.

        The token is disabled.
        """
        self.request.auth = Token.objects.create()
        self.assertFalse(
            self.is_token_authenticated.has_permission(self.request, APIView())
        )


class IsTokenUserAuthenticatedTests(TestCase):
    """Tests for IsTokenUserAuthenticated class."""

    def setUp(self) -> None:
        """Set up test."""
        self.is_token_user_authenticated = IsTokenUserAuthenticated()
        self.request = Request(HttpRequest())

    def test_request_disabled_token(self) -> None:
        """Request with a Token header that is disabled: permission denied."""
        self.request.auth = Token.objects.create()
        self.assertFalse(self.request.auth.enabled)
        self.assertFalse(
            self.is_token_user_authenticated.has_permission(
                self.request, APIView()
            )
        )

    def test_request_non_existing_token(self) -> None:
        """Request without a token."""
        self.request.auth = None
        self.assertFalse(
            self.is_token_user_authenticated.has_permission(
                self.request, APIView()
            )
        )

    def test_request_enabled_token_without_user(self) -> None:
        """Request with a Token that does not have a user: permission denied."""
        self.request.auth = self.playground.create_bare_token()
        self.assertFalse(
            self.is_token_user_authenticated.has_permission(
                self.request, APIView()
            )
        )

    def test_request_enabled_token_with_user(self) -> None:
        """Request with a Token that does have a user: permission granted.."""
        self.request.auth = self.playground.create_user_token()
        self.assertTrue(
            self.is_token_user_authenticated.has_permission(
                self.request, APIView()
            )
        )


class IsGetTests(TestCase):
    """Tests for IsGet."""

    def setUp(self) -> None:
        """Set up tests."""
        self.is_get = IsGet()
        self.http_request = Request(HttpRequest())

    def test_request_is_get(self) -> None:
        """Request method is GET. Return True."""
        self.http_request.method = "GET"

        self.assertTrue(
            self.is_get.has_permission(self.http_request, APIView())
        )

    def test_request_is_post(self) -> None:
        """Request method is POST. Return False."""
        self.http_request.method = "POST"

        self.assertFalse(
            self.is_get.has_permission(self.http_request, APIView())
        )


class UploadFileViewTests(TestCase):
    """Tests for UploadFileView class."""

    @context.disable_permission_checks()
    def setUp(self) -> None:
        """Set up objects for the tests."""
        self.token = self.playground.create_worker_token()
        self.path_in_artifact = "README"
        self.file_size = 100
        self.artifact, self.files_contents = self.playground.create_artifact(
            [self.path_in_artifact], files_size=self.file_size
        )
        self.file_contents = self.files_contents[self.path_in_artifact]

        self.file_hash = self.hash_data(self.file_contents)

        self.file = self.playground.create_file(self.file_contents)
        self.file_in_artifact = FileInArtifact.objects.create(
            artifact=self.artifact, path=self.path_in_artifact, file=self.file
        )

    def tearDown(self) -> None:
        """If FileUpload for the file exist: delete it."""
        try:
            file_upload = FileUpload.objects.get(
                file_in_artifact=self.file_in_artifact
            )
        except FileUpload.DoesNotExist:
            return

        with self.captureOnCommitCallbacks(execute=True):
            file_upload.delete()

    @staticmethod
    def hash_data(data: bytes) -> str:
        """Return hex digest in hex of data."""
        hasher = hashlib.new(File.current_hash_algorithm)
        hasher.update(data)
        return hasher.digest().hex()

    def test_check_permissions(self) -> None:
        """Only authenticated requests are processed by the view."""
        self.assertIn(
            IsTokenAuthenticated,
            UploadFileView.permission_classes,
        )

    def put_file(
        self,
        range_start: int | None = None,
        range_end: int | None = None,
        extra_put_params: dict[str, str] | None = None,
        *,
        path_in_artifact: str | None = None,
        artifact_id: int | None = None,
        forced_data: bytes | None = None,
        include_data: bool = True,
        token: Token | None = None,
        scope: str | None = None,
    ) -> TestResponseType:
        """
        Upload file to debusine using the API.

        Use data in self.test_data

        :param range_start: use it with range_end
        :param range_end: if specified, upload a range of data
          use Content-Range: bytes $range_start-$range_end and attaches only
          the correct part of the data
        :param extra_put_params: add parameters to self.client.put
          (e.g. HTTP headers)
        :param path_in_artifact: path in the artifact to upload the file
        :param artifact_id: if not None: artifact_id to use,
          else self.artifact.id
        :param forced_data: if not None: use this data. Else,
          use self.test_data
        :param include_data: if True includes the data (following specified
          range)
        :param token: if not None: token to authenticate with, else
          self.token
        :param scope: if not None: X-Debusine-Scope header to send
        """
        parameters: dict[str, Any] = {}

        if forced_data is not None:
            data = forced_data
        else:
            data = self.file_contents

        if range_start is not None:
            if range_end is None:
                raise ValueError(
                    "range_end must have an int if range_start is not None"
                )  # pragma: no cover
            data = data[range_start : range_end + 1]
            file_size = len(data)
            parameters.update(
                **{
                    "HTTP_CONTENT_LENGTH": range_end + 1 - range_start,
                    "HTTP_CONTENT_RANGE": f"bytes {range_start}-{range_end}"
                    f"/{file_size}",
                }
            )

        if scope is not None:
            parameters["HTTP_X_DEBUSINE_SCOPE"] = scope

        if extra_put_params is not None:
            parameters.update(**extra_put_params)

        if include_data:
            parameters["data"] = data

        if path_in_artifact is None:
            path_in_artifact = self.path_in_artifact

        if artifact_id is not None:
            artifact_id = artifact_id
        else:
            artifact_id = self.artifact.id

        with self.captureOnCommitCallbacks(execute=True):
            # captureOnCommitCallbacks(execute=True) to force File.delete()
            # callback to delete the file to be executed
            return self.client.put(
                reverse(
                    "api:upload-file",
                    kwargs={
                        "artifact_id": artifact_id,
                        "file_path": path_in_artifact,
                    },
                ),
                content_type="application/octet-stream",
                HTTP_TOKEN=(token or self.token).key,
                **parameters,
            )

    def test_put_file_in_subdirectory(self) -> None:
        """Client PUT a file that is in a subdirectory (src/README)."""
        data = b"test"
        path_in_artifact = "src/README"

        file = self.playground.create_file(data)
        file_in_artifact = FileInArtifact.objects.create(
            artifact=self.artifact, path=path_in_artifact, file=file
        )

        response = self.put_file(
            path_in_artifact=path_in_artifact,
            forced_data=b"test",
            include_data=True,
        )
        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        file_in_artifact.refresh_from_db()
        self.assertTrue(file_in_artifact.complete)

    def test_put_whole_file(self) -> None:
        """Server receive a file without Content-Range. Whole file."""
        self.assertEqual(FileInStore.objects.count(), 0)

        response = self.put_file()

        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.assertEqual(FileInStore.objects.count(), 1)

        file = FileInStore.objects.all()[0].file

        self.assertEqual(file.hash_digest.hex(), self.file_hash)
        self.assertEqual(file.size, self.file_size)
        self.file_in_artifact.refresh_from_db()
        self.assertTrue(self.file_in_artifact.complete)

    def test_put_wrong_whole_file(self) -> None:
        """Server receive a file without Content-Range. Unexpected hash."""
        files_in_uploads_before = FileUpload.objects.all().count()

        self.assertEqual(FileUpload.objects.count(), 0)

        data = b"x" * self.file_size

        response = self.put_file(forced_data=data)

        self.assertEqual(FileUpload.objects.count(), 0)

        hash_digest_expected = self.file_hash
        hash_digest_received = self.hash_data(data)

        self.assertResponseProblem(
            response,
            f"Invalid file hash. Expected: {hash_digest_expected} "
            f"Received: {hash_digest_received}",
            status_code=status.HTTP_409_CONFLICT,
        )

        self.assertEqual(
            files_in_uploads_before, FileUpload.objects.all().count()
        )
        self.file_in_artifact.refresh_from_db()
        self.assertFalse(self.file_in_artifact.complete)

    def test_put_already_uploaded_file_return_created(self) -> None:
        """Server return 201 created for a file that is already uploaded."""
        # Upload the file once
        response = self.put_file()
        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.file_in_artifact.refresh_from_db()
        self.assertTrue(self.file_in_artifact.complete)

        # Upload the same file again (can happen if two clients are uploading
        # the same file)
        response = self.put_file()
        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.file_in_artifact.refresh_from_db()
        self.assertTrue(self.file_in_artifact.complete)

    def test_put_big_file(self) -> None:
        """
        Server receive a file bigger than DATA_UPLOAD_MAX_MEMORY_SIZE.

        Ensure to exercise code for bigger files (read in multiple chunks and
        Django streaming it from disk instead of memory).
        """
        self.assertEqual(FileInStore.objects.count(), 0)

        file_size = settings.DATA_UPLOAD_MAX_MEMORY_SIZE + 1000
        data = next(data_generator(file_size))
        path_in_artifact = self.path_in_artifact + ".2"
        file = self.playground.create_file(data)
        file_in_artifact = FileInArtifact.objects.create(
            artifact=self.artifact, path=path_in_artifact, file=file
        )

        response = self.put_file(
            path_in_artifact=path_in_artifact,
            forced_data=data,
            include_data=True,
        )

        file = FileInStore.objects.all()[0].file

        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.assertEqual(file.hash_digest.hex(), self.hash_data(data))
        self.assertEqual(file.size, file_size)
        file_in_artifact.refresh_from_db()
        self.assertTrue(file_in_artifact.complete)

    def test_put_last_activity_updated(self) -> None:
        """Server updates FileUpload.last_activity_at on each received part."""
        # Upload first part of the file
        start_range = 0
        end_range = 20

        self.put_file(start_range, end_range)

        file_upload = self.file_in_artifact.fileupload

        # Last activity got updated and is less than now
        last_activity_1 = file_upload.last_activity_at
        self.assertLess(last_activity_1, timezone.now())

        start_range = end_range + 1
        end_range = 30

        # Uploads another part of the file (but not all)
        self.put_file(start_range, end_range)

        file_upload.refresh_from_db()

        last_activity_2 = file_upload.last_activity_at

        # Last activity got updated: greater than earlier, less than now
        self.assertGreater(last_activity_2, last_activity_1)
        self.assertLess(last_activity_2, timezone.now())

    def test_put_two_parts_file_completed(self) -> None:
        """Server receive a file in two parts."""
        files_in_uploads_before = FileUpload.objects.all().count()

        # Upload first part of the file
        start_range = 0
        end_range = 44

        response = self.put_file(start_range, end_range)

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(response.headers["range"], f"bytes=0-{end_range + 1}")

        file_upload_path = self.file_in_artifact.fileupload.path
        self.assertFalse(Path(file_upload_path).is_absolute())
        self.assertFalse(self.file_in_artifact.complete)

        # Upload second part of the file
        start_range = end_range + 1
        end_range = self.file_size = 100 - 1

        response = self.put_file(start_range, end_range)

        self.assertEqual(response.status_code, status.HTTP_201_CREATED)

        # Assert that debusine created the file as expected in the database
        self.assertEqual(self.artifact.files.all().count(), 1)

        self.file_in_artifact.refresh_from_db()
        file = self.file_in_artifact.file
        self.assertEqual(file.hash_digest.hex(), self.file_hash)
        self.assertEqual(self.artifact.files.all().count(), 1)
        self.assertEqual(FileInStore.objects.count(), 1)

        self.assertEqual(
            files_in_uploads_before,
            FileUpload.objects.all().count(),
        )
        self.assertTrue(self.file_in_artifact.complete)

    def test_put_two_parts_file_completed_wrong_hash(self) -> None:
        """Server receive a file in two parts. Hash mismatch with expected."""
        files_in_uploads_before = FileUpload.objects.all().count()
        self.assertEqual(FileUpload.objects.count(), 0)

        # Upload first part of the file
        start_range_1 = 0
        end_range_1 = 44
        data_1 = self.file_contents
        response = self.put_file(start_range_1, end_range_1, forced_data=data_1)
        self.assertEqual(response.status_code, status.HTTP_200_OK)

        # Upload second part of the file (wrong hash)
        start_range_2 = end_range_1 + 1
        end_range_2 = self.file_size - 1
        data_2 = b"x" * self.file_size

        response = self.put_file(start_range_2, end_range_2, forced_data=data_2)

        expected_hash = self.file_hash

        received_hash = self.hash_data(
            data_1[start_range_1 : end_range_1 + 1]
            + data_2[start_range_2 : end_range_2 + 1]
        )

        self.assertResponseProblem(
            response,
            f"Invalid file hash. Expected: {expected_hash} "
            f"Received: {received_hash}",
            status_code=status.HTTP_409_CONFLICT,
        )

        # Temporary uploaded data is removed so the client can try again:
        self.assertEqual(FileUpload.objects.all().count(), 0)
        self.assertEqual(
            files_in_uploads_before,
            FileUpload.objects.all().count(),
        )
        self.file_in_artifact.refresh_from_db()
        self.assertFalse(self.file_in_artifact.complete)

    def test_put_content_range_star_size_partial_content(self) -> None:
        """
        Client PUT Content-Range: */$SIZE server return HTTP 206: partial file.

        The file has not started to be uploaded: response Range == bytes 0-0
        """  # noqa: RST213
        size = self.file.size

        file_upload = self.create_file_uploaded()
        file_upload.absolute_file_path().unlink()

        response = self.put_file(
            extra_put_params={"HTTP_CONTENT_RANGE": f"bytes */{size}"},
            include_data=False,
        )

        self.assertEqual(response.status_code, status.HTTP_206_PARTIAL_CONTENT)

        # Nothing has been uploaded yet
        self.assertEqual(response.headers["range"], "bytes=0-0")
        self.file_in_artifact.refresh_from_db()
        self.assertFalse(self.file_in_artifact.complete)

    def test_put_gap_in_file_return_http_400(self) -> None:
        """
        Client PUT Content-Range: 40/99 having previously uploaded 30 bytes.

        Server responds with HTTP 400 because the second range starts after
        the received data.
        """
        upload_to_position = 30
        self.create_file_uploaded(upload_to_position)

        start_range = 40
        end_range = 99
        response = self.put_file(start_range, end_range)

        self.assertResponseProblem(
            response,
            f"Server had received {upload_to_position} bytes of the file. "
            f"New upload starts at {start_range}. "
            f"Please continue from {upload_to_position}",
        )
        self.file_in_artifact.refresh_from_db()
        self.assertFalse(self.file_in_artifact.complete)

    def test_put_continue_uploaded_file_without_content_range(self) -> None:
        """
        Client PUT without Content-Range and previously uploaded part of a file.

        Server responds with completed (it will overwrite the existing
        uploaded part of the file).
        """
        self.create_file_uploaded(30)

        response = self.put_file()

        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.file_in_artifact.refresh_from_db()
        self.assertTrue(self.file_in_artifact.complete)

    def create_file_uploaded(
        self, upload_size: int | None = None
    ) -> FileUpload:
        """
        Create an uploaded file (FileUpload and write data).

        Write self.file_in_artifact.file.size bytes or upload_size if not None.
        """
        temporary_file = self.create_temporary_file()

        file_in_artifact = FileInArtifact.objects.get(
            path=self.path_in_artifact, artifact=self.artifact
        )

        file_upload = FileUpload.objects.create(
            file_in_artifact=file_in_artifact,
            path=str(temporary_file),
        )

        if upload_size is None:
            upload_size = file_in_artifact.file.size

        file_upload.absolute_file_path().write_bytes(
            self.file_contents[:upload_size]
        )

        return file_upload

    def test_put_content_range_star_size_file_not_uploaded_yet(self) -> None:
        """
        Client PUT Content-Range: */$SIZE. Receive HTTP 206: whole file needed.

        The client had not uploaded any part of the file.
        """  # noqa: RST213
        response = self.put_file(
            extra_put_params={
                "HTTP_CONTENT_RANGE": f"bytes */{self.file_size}"
            },
            include_data=False,
        )
        self.assertEqual(response.status_code, status.HTTP_206_PARTIAL_CONTENT)
        self.assertEqual(response.headers["range"], "bytes=0-0")
        self.file_in_artifact.refresh_from_db()
        self.assertFalse(self.file_in_artifact.complete)

    def test_put_content_range_star_size_file_already_uploaded(self) -> None:
        """
        Client PUT Content-Range: */$SIZE. Receive HTTP 200.

        The file was already uploaded.
        """  # noqa: RST213
        self.create_file_uploaded()

        response = self.put_file(
            extra_put_params={
                "HTTP_CONTENT_RANGE": f"bytes */{self.file_size}"
            },
            include_data=False,
        )
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.file_in_artifact.refresh_from_db()
        self.assertFalse(self.file_in_artifact.complete)

    def test_put_content_range_file_size_star(self) -> None:
        """
        Client PUT Content-Range: */*. Receive HTTP 200.

        The file was already uploaded.
        """  # noqa: RST213
        self.create_file_uploaded()
        response = self.put_file(
            extra_put_params={"HTTP_CONTENT_RANGE": "bytes */*"},
            include_data=False,
        )
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.file_in_artifact.refresh_from_db()
        self.assertFalse(self.file_in_artifact.complete)

    def test_put_content_range_invalid_file_size(self) -> None:
        """
        Client PUT Content-Range: */$WRONG_SIZE, receive error from the server.

        The server expected a different size than $WRONG_SIZE.
        """  # noqa: RST213
        FileUpload.objects.create(
            file_in_artifact=self.file_in_artifact,
            path="not-used-in-this-test",
        )

        content_range_length = self.file_size + 10
        content_range = f"bytes */{content_range_length}"

        response = self.put_file(
            extra_put_params={"HTTP_CONTENT_RANGE": content_range}
        )

        self.assertResponseProblem(
            response,
            "Invalid file size in Content-Range header. "
            f"Expected {self.file_size} received {content_range_length}",
        )
        self.file_in_artifact.refresh_from_db()
        self.assertFalse(self.file_in_artifact.complete)

    def test_put_artifact_does_not_exist(self) -> None:
        """Client PUT to artifact_id that does not exist. Return HTTP 404."""
        FileInArtifact.objects.all().delete()
        Artifact.objects.all().delete()

        invalid_artifact_id = 1
        response = self.put_file(artifact_id=invalid_artifact_id)

        self.assertResponseProblem(
            response,
            f"Artifact {invalid_artifact_id} does not exist",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_put_honours_scope(self) -> None:
        """PUT checks that the artifact is in the current scope."""
        scope1 = self.playground.get_or_create_scope("scope1")
        scope2 = self.playground.get_or_create_scope("scope2")
        workspace = self.playground.create_workspace(
            scope=scope1, name="workspace", public=True
        )
        self.artifact.workspace = workspace
        self.artifact.save()
        self.assertEqual(FileInStore.objects.count(), 0)

        response = self.put_file(scope=scope2.name)

        self.assertResponseProblem(
            response,
            f"Artifact {self.artifact.id} does not exist",
            status_code=status.HTTP_404_NOT_FOUND,
        )

        response = self.put_file(scope=scope1.name)

        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.assertEqual(FileInStore.objects.count(), 1)

    def test_put_private_workspace_unauthorized(self) -> None:
        """Artifacts in private workspaces 404 to the unauthorized."""
        private_workspace = self.playground.create_workspace(name="Private")
        self.artifact.workspace = private_workspace
        self.artifact.save()
        token = self.playground.create_user_token()
        assert token.user is not None

        response = self.put_file(
            token=token, scope=private_workspace.scope.name
        )

        self.assertResponseProblem(
            response,
            f"Artifact {self.artifact.id} does not exist",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_put_private_workspace_authorized(self) -> None:
        """Artifacts in private workspaces succeed for the authorized."""
        private_workspace = self.playground.create_workspace(name="Private")
        self.artifact.workspace = private_workspace
        self.artifact.save()
        token = self.playground.create_user_token()
        assert token.user is not None
        self.playground.create_group_role(
            private_workspace, Workspace.Roles.OWNER, token.user
        )
        self.assertEqual(FileInStore.objects.count(), 0)

        response = self.put_file(
            token=token, scope=private_workspace.scope.name
        )

        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.assertEqual(FileInStore.objects.count(), 1)

    def test_put_artifact_file_path_does_not_exist(self) -> None:
        """
        Client upload a file_path that does not exist in artifact.

        Server return HTTP 404.
        """
        invalid_path = FileInArtifact.objects.all()[0].path + ".tmp"

        response = self.put_file(path_in_artifact=invalid_path)

        self.assertResponseProblem(
            response,
            f'No file_path "{invalid_path}" for artifact {self.artifact.id}',
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_put_too_big_data(self) -> None:
        """
        Client send more data than it was expected based on the range.

        Server return HTTP 400.
        """
        start_range = 5
        end_range = 10
        data = self.file_contents[start_range:end_range]
        data_length = len(data)
        content_range = f"bytes {start_range}-{end_range}/{data_length}"

        response = self.put_file(
            extra_put_params={
                'HTTP_CONTENT_LENGTH': str(data_length),
                'HTTP_CONTENT_RANGE': content_range,
            },
            forced_data=data,
        )

        self.assertResponseProblem(
            response,
            f"Expected {end_range - start_range + 1} bytes (based on range "
            f"header: end-start+1). Received: {data_length} bytes",
            status_code=status.HTTP_400_BAD_REQUEST,
        )
        self.file_in_artifact.refresh_from_db()
        self.assertFalse(self.file_in_artifact.complete)

    def test_end_less_than_start(self) -> None:
        """Client send an invalid range: start is greater than file size."""
        file_length = len(self.file_contents)
        start_range = file_length + 10
        end_range = start_range + file_length - 1

        response = self.put_file(
            range_start=start_range,
            range_end=end_range,
        )

        self.assertResponseProblem(
            response,
            f"Range start ({start_range}) is greater than "
            f"file size ({file_length})",
            status_code=status.HTTP_400_BAD_REQUEST,
        )
        self.file_in_artifact.refresh_from_db()
        self.assertFalse(self.file_in_artifact.complete)

    def test_range_upload_data_after_end_of_the_file(self) -> None:
        """Client send data that would be written after the end of the file."""
        chunk = self.file_contents[0:20]
        chunk_length = len(chunk)
        start_range = self.file_size - 10
        end_range = start_range + chunk_length - 1
        content_range = f"bytes {start_range}-{end_range}/{self.file_size}"

        response = self.put_file(
            extra_put_params={
                'HTTP_CONTENT_RANGE': content_range,
            },
            forced_data=chunk,
        )

        self.assertResponseProblem(
            response,
            "Cannot process range: attempted to write after end of the file. "
            f"Range start: {start_range} Received: {chunk_length} "
            f"File size: {self.file_size}",
            status_code=status.HTTP_400_BAD_REQUEST,
        )
        self.file_in_artifact.refresh_from_db()
        self.assertFalse(self.file_in_artifact.complete)

    def test_invalid_content_range_header_bytes(self) -> None:
        """Client send invalid Content-Range header. Server return HTTP 400."""
        invalid_header = "bytes something-not-valid"

        response = self.put_file(
            extra_put_params={
                'HTTP_CONTENT_RANGE': invalid_header,
            }
        )

        self.assertResponseProblem(
            response, f'Invalid Content-Range header: "{invalid_header}"'
        )
        self.file_in_artifact.refresh_from_db()
        self.assertFalse(self.file_in_artifact.complete)

    def test_invalid_content_range_header_file_size_partial(self) -> None:
        """Client send invalid content-range header. Server return HTTP 400."""
        invalid_header = "a/b"

        response = self.put_file(
            extra_put_params={
                'HTTP_CONTENT_RANGE': invalid_header,
            },
        )

        self.assertResponseProblem(
            response, f'Invalid Content-Range header: "{invalid_header}"'
        )
        self.file_in_artifact.refresh_from_db()
        self.assertFalse(self.file_in_artifact.complete)

    def test_create_file_in_storage(self) -> None:
        """Test create_file_in_storage() creates in correct FileStore."""
        file_upload = self.create_file_uploaded()
        file_id = file_upload.file_in_artifact.file.id
        workspace = file_upload.file_in_artifact.artifact.workspace

        # Create the file in the storage
        UploadFileView.create_file_in_storage(file_upload)

        file_store_expected = workspace.default_file_store

        # The file got created in the workspace.default_file_store
        self.assertTrue(file_store_expected.files.filter(id=file_id).exists())

        # Create a new FileStore and change workspace.default_file_store
        new_file_store = FileStore.objects.create(
            name="nas-01",
            backend=FileStore.BackendChoices.LOCAL,
            configuration={"base_directory": settings.DEBUSINE_STORE_DIRECTORY},
        )
        workspace.default_file_store = new_file_store
        workspace.save()

        # Create the file in the storage
        UploadFileView.create_file_in_storage(file_upload)

        # It got created in the new FileStore
        self.assertTrue(new_file_store.files.filter(id=file_id).exists())


class ArtifactRelationsViewTests(TestCase):
    """Tests for ArtifactRelationsView class."""

    scenario = scenarios.DefaultContextAPI()

    @context.disable_permission_checks()
    def setUp(self) -> None:
        """Initialize object."""
        self.artifact = Artifact.objects.create(
            category="test", workspace=self.scenario.workspace
        )
        self.artifact_target = Artifact.objects.create(
            category="test", workspace=self.scenario.workspace
        )
        self.client = APIClient()

    @staticmethod
    def request_relations(
        method: Callable[..., HttpResponseBase],
        *,
        artifact_id: int | None,
        target_artifact_id: int | None,
        body: dict[str, Any] | None = None,
        token: Token | None = None,
        scope: str | None = None,
    ) -> HttpResponseBase:
        """
        Call method with reverse("api:artifact-relation-list").

        :param method: method to call (usually self.client.get/post/delete)
        :param artifact_id: used to create the URL (passed as artifact_id)
        :param target_artifact_id:
        :param body: if not None passed as method's data argument and
          make content_type="application/json".
        :param token: if not None: token to authenticate with.
        :param scope: if not None: X-Debusine-Scope header to send.
        """
        params = {}
        if artifact_id is not None:
            params["artifact"] = artifact_id

        if target_artifact_id is not None:
            params["target_artifact"] = target_artifact_id

        post_kwargs = {}
        post_args = []
        if body is not None:
            post_kwargs["format"] = "json"
            post_args.append(body)

        if token is not None:
            post_kwargs["HTTP_TOKEN"] = token.key

        if scope is not None:
            post_kwargs["HTTP_X_DEBUSINE_SCOPE"] = scope

        query_string = urlencode(params)

        response = method(
            reverse(
                "api:artifact-relation-list",
            )
            + f"?{query_string}",
            *post_args,
            **post_kwargs,
        )
        assert isinstance(response, HttpResponseBase)
        return response

    def get_relations(
        self,
        *,
        artifact_id: int | None = None,
        target_artifact_id: int | None = None,
        token: Token | None = None,
        scope: str | None = None,
    ) -> HttpResponseBase:
        """
        Make an HTTP GET request to api:artifact-relation-list URL.

        :param artifact_id: used in query parameter "artifact"
        :param target_artifact_id: used in query parameter "target_artifact"
        :param token: if not None: token to authenticate with.
        :param scope: if not None: X-Debusine-Scope header to send.
        """
        return self.request_relations(
            self.client.get,
            artifact_id=artifact_id,
            target_artifact_id=target_artifact_id,
            token=token,
            scope=scope,
        )

    def post_relation(
        self,
        body: dict[str, Any] | None = None,
        token: Token | None = None,
        scope: str | None = None,
    ) -> HttpResponseBase:
        """
        Make an HTTP POST request to api:artifact-relation-list URL.

        :param body: body of the request
        :param token: if not None: token to authenticate with.
        :param scope: if not None: X-Debusine-Scope header to send.
        """
        return self.request_relations(
            self.client.post,
            artifact_id=None,
            target_artifact_id=None,
            body=body,
            token=token,
            scope=scope,
        )

    def delete_relation(
        self,
        artifact_relation_id: int,
        token: Token | None = None,
        scope: str | None = None,
    ) -> TestResponseType:
        """
        Make an HTTP DELETE request to api:artifact-relation-detail URL.

        :param artifact_relation_id: ID of relation to delete.
        :param token: if not None: token to authenticate with.
        :param scope: if not None: X-Debusine-Scope header to send.
        """
        headers = {}
        if token is not None:
            headers["Token"] = token.key
        if scope is not None:
            headers["X-Debusine-Scope"] = scope
        return self.client.delete(
            reverse(
                "api:artifact-relation-detail",
                kwargs={"pk": artifact_relation_id},
            ),
            headers=headers,
        )

    def test_get_return_404_artifact_not_found(self) -> None:
        """Get relations for a non-existing artifact: return 404."""
        artifact_id = 0
        response = self.get_relations(artifact_id=artifact_id)

        self.assertResponseProblem(
            response,
            f"Artifact {artifact_id} not found",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_get_return_404_target_artifact_not_found(self) -> None:
        """Get target relations for a non-existing artifact: return 404."""
        artifact_id = 0
        response = self.get_relations(target_artifact_id=artifact_id)

        self.assertResponseProblem(
            response,
            f"Artifact {artifact_id} not found",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_get_return_400_mandatory_parameters_missing(self) -> None:
        """Get relations: fails because missing mandatory parameters."""
        response = self.get_relations()

        self.assertResponseProblem(
            response, '"artifact" or "target_artifact" parameter are mandatory'
        )

    def create_relations(self) -> None:
        """Create two relations to be used in the tests."""
        ArtifactRelation.objects.create(
            artifact=self.artifact,
            target=self.artifact_target,
            type=ArtifactRelation.Relations.BUILT_USING,
        )
        ArtifactRelation.objects.create(
            artifact=self.artifact,
            target=self.artifact_target,
            type=ArtifactRelation.Relations.RELATES_TO,
        )

    def test_get_200_target_artifact_relations(self) -> None:
        """Get return target artifact relations."""
        with context.disable_permission_checks():
            self.create_relations()

            another_artifact = Artifact.objects.create(
                category=self.artifact.category,
                workspace=self.artifact.workspace,
            )
            ArtifactRelation.objects.create(
                artifact=self.artifact,
                target=another_artifact,
                type=ArtifactRelation.Relations.RELATES_TO,
            )

        response = self.get_relations(
            target_artifact_id=self.artifact_target.id
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)

        assert isinstance(response, JSONResponseProtocol)
        self.assertEqual(
            len(response.json()), self.artifact_target.targeted_by.count()
        )

        for artifact_relation in response.json():
            ArtifactRelation.objects.get(
                artifact_id=artifact_relation["artifact"],
                target_id=artifact_relation["target"],
                type=artifact_relation["type"],
            )

    def test_get_200_artifact_relations(self) -> None:
        """Get return artifact relations."""
        self.create_relations()

        response = self.get_relations(artifact_id=self.artifact.id)

        self.assertEqual(response.status_code, status.HTTP_200_OK)

        assert isinstance(response, JSONResponseProtocol)
        self.assertEqual(
            len(response.json()), ArtifactRelation.objects.all().count()
        )

        for artifact_relation in response.json():
            ArtifactRelation.objects.get(
                artifact_id=self.artifact.id,
                target_id=artifact_relation["target"],
                type=artifact_relation["type"],
            )

    def test_get_honours_scope(self) -> None:
        """Getting relations looks up the artifact in the current scope."""
        scope1 = self.playground.get_or_create_scope("scope1")
        scope2 = self.playground.get_or_create_scope("scope2")
        workspace = self.playground.create_workspace(
            scope=scope1, name="workspace", public=True
        )
        self.artifact.workspace = workspace
        self.artifact.save()
        self.artifact_target.workspace = workspace
        self.artifact_target.save()
        self.create_relations()

        response = self.get_relations(
            artifact_id=self.artifact.id,
            token=self.scenario.user_token,
            scope=workspace.scope.name,
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        assert isinstance(response, JSONResponseProtocol)
        self.assertEqual(
            len(response.json()), ArtifactRelation.objects.all().count()
        )

        response = self.get_relations(
            artifact_id=self.artifact.id,
            token=self.scenario.user_token,
            scope=scope2.name,
        )

        self.assertResponseProblem(
            response,
            f"Artifact {self.artifact.id} not found",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_get_private_workspace_unauthorized(self) -> None:
        """Artifacts in private workspaces 404 to the unauthorized."""
        private_workspace = self.playground.create_workspace(name="Private")
        self.artifact.workspace = private_workspace
        self.artifact.save()
        self.artifact_target.workspace = private_workspace
        self.artifact_target.save()

        response = self.get_relations(
            artifact_id=self.artifact.id,
            token=self.scenario.user_token,
            scope=private_workspace.scope.name,
        )

        self.assertResponseProblem(
            response,
            f"Artifact {self.artifact.id} not found",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_get_private_workspace_authorized(self) -> None:
        """Artifacts in private workspaces 200 to the authorized."""
        private_workspace = self.playground.create_workspace(name="Private")
        self.playground.create_group_role(
            private_workspace, Workspace.Roles.OWNER, self.scenario.user
        )
        self.artifact.workspace = private_workspace
        self.artifact.save()
        self.artifact_target.workspace = private_workspace
        self.artifact_target.save()

        response = self.get_relations(
            artifact_id=self.artifact.id,
            token=self.scenario.user_token,
            scope=private_workspace.scope.name,
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        assert isinstance(response, JSONResponseProtocol)
        self.assertEqual(
            len(response.json()), ArtifactRelation.objects.all().count()
        )

    def test_post_400_artifact_id_does_not_exist(self) -> None:
        """Post the relations for a non-existing artifact: return 400."""
        artifact_id = 0
        artifact_relation = {
            "artifact": artifact_id,
            "target": self.artifact.id,
            "type": ArtifactRelation.Relations.RELATES_TO,
        }

        response = self.post_relation(body=artifact_relation)

        self.assertResponseProblem(
            response,
            "Cannot deserialize artifact relation",
            validation_errors_pattern="Invalid pk",
            status_code=status.HTTP_400_BAD_REQUEST,
        )

    def test_post_400_cannot_deserialize(self) -> None:
        """Add a new relation for an artifact: HTTP 400 cannot deserialize."""
        response = self.post_relation(body={"foo": "bar"})

        self.assertResponseProblem(
            response,
            title="Cannot deserialize artifact relation",
            validation_errors_pattern="This field is required",
        )

    def test_post_201_created(self) -> None:
        """Add a new relation for an artifact."""
        self.assertEqual(self.artifact.relations.all().count(), 0)
        relation = {
            "artifact": self.artifact.id,
            "target": self.artifact_target.id,
            "type": ArtifactRelation.Relations.RELATES_TO,
        }

        response = self.post_relation(body=relation)

        assert isinstance(response, JSONResponseProtocol)
        response_data = response.json()

        self.assertEqual(response.status_code, status.HTTP_201_CREATED)

        relation["id"] = ArtifactRelation.objects.all()[0].id
        self.assertEqual(response_data, relation)

        ArtifactRelation.objects.get(
            id=response_data["id"],
            artifact_id=response_data["artifact"],
            target_id=response_data["target"],
            type=response_data["type"],
        )

    def test_post_200_duplicated(self) -> None:
        """Try to add a relation that already exists: return HTTP 200."""
        artifact_relation = ArtifactRelation.objects.create(
            artifact=self.artifact,
            target=self.artifact_target,
            type=ArtifactRelation.Relations.BUILT_USING,
        )

        relation = {
            "artifact": artifact_relation.artifact.id,
            "target": artifact_relation.target.id,
            "type": str(artifact_relation.type),
        }

        response = self.post_relation(body=relation)

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        relation["id"] = artifact_relation.id
        assert isinstance(response, JSONResponseProtocol)
        self.assertEqual(response.json(), relation)

    def test_post_400_invalid_target_id(self) -> None:
        """No relations added to the artifact: invalid target id."""
        self.assertEqual(self.artifact.relations.all().count(), 0)

        relation = {
            "artifact": self.artifact.id,
            "target": 0,
            "type": ArtifactRelation.Relations.RELATES_TO,
        }

        response = self.post_relation(body=relation)

        self.assertResponseProblem(
            response,
            title="Cannot deserialize artifact relation",
            validation_errors_pattern="Invalid pk",
        )

    def test_post_honours_scope(self) -> None:
        """Getting relations looks up the artifact in the current scope."""
        scope1 = self.playground.get_or_create_scope("scope1")
        scope2 = self.playground.get_or_create_scope("scope2")
        workspace = self.playground.create_workspace(
            scope=scope1, name="workspace", public=True
        )
        self.artifact.workspace = workspace
        self.artifact.save()
        self.artifact_target.workspace = workspace
        self.artifact_target.save()
        relation = {
            "artifact": self.artifact.id,
            "target": self.artifact_target.id,
            "type": ArtifactRelation.Relations.RELATES_TO,
        }

        response = self.post_relation(
            body=relation, token=self.scenario.user_token, scope=scope2.name
        )

        self.assertResponseProblem(
            response,
            "Workspace not found",
            detail_pattern="Workspace workspace not found in scope scope2",
            status_code=status.HTTP_404_NOT_FOUND,
        )

        response = self.post_relation(
            body=relation,
            token=self.scenario.user_token,
            scope=workspace.scope.name,
        )

        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        assert isinstance(response, JSONResponseProtocol)
        relation["id"] = ArtifactRelation.objects.all()[0].id
        self.assertEqual(response.json(), relation)

    def test_post_private_workspace_unauthorized(self) -> None:
        """Creating relations in private workspaces 404s to the unauthorized."""
        private_workspace = self.playground.create_workspace(name="Private")
        self.artifact.workspace = private_workspace
        self.artifact.save()
        self.artifact_target.workspace = private_workspace
        self.artifact_target.save()
        relation = {
            "artifact": self.artifact.id,
            "target": self.artifact_target.id,
            "type": ArtifactRelation.Relations.RELATES_TO,
        }

        response = self.post_relation(
            body=relation,
            token=self.scenario.user_token,
            scope=private_workspace.scope.name,
        )

        self.assertResponseProblem(
            response,
            "Workspace not found",
            detail_pattern=(
                f"Workspace Private not found in scope "
                f"{private_workspace.scope.name}"
            ),
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_post_private_workspace_authorized(self) -> None:
        """Creating relations in private workspaces 200s to the authorized."""
        private_workspace = self.playground.create_workspace(name="Private")
        self.playground.create_group_role(
            private_workspace, Workspace.Roles.OWNER, self.scenario.user
        )
        self.artifact.workspace = private_workspace
        self.artifact.save()
        self.artifact_target.workspace = private_workspace
        self.artifact_target.save()
        relation = {
            "artifact": self.artifact.id,
            "target": self.artifact_target.id,
            "type": ArtifactRelation.Relations.RELATES_TO,
        }

        response = self.post_relation(
            body=relation,
            token=self.scenario.user_token,
            scope=private_workspace.scope.name,
        )

        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        assert isinstance(response, JSONResponseProtocol)
        relation["id"] = ArtifactRelation.objects.all()[0].id
        self.assertEqual(response.json(), relation)

    def test_delete_404_artifact_not_found(self) -> None:
        """Delete relation: cannot find artifact."""
        artifact_relation_id = 0
        response = self.delete_relation(artifact_relation_id)

        self.assertResponseProblem(
            response,
            "No ArtifactRelation matches the given query.",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_delete_204_success(self) -> None:
        """Delete relation."""
        artifact_relation = ArtifactRelation.objects.create(
            artifact=self.artifact,
            target=self.artifact_target,
            type=ArtifactRelation.Relations.RELATES_TO,
        )

        response = self.delete_relation(artifact_relation.id)

        self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
        self.assertEqual(self.artifact.relations.all().count(), 0)

    def test_delete_honours_scope(self) -> None:
        """Deleting relations looks up the artifact in the current scope."""
        scope1 = self.playground.get_or_create_scope("scope1")
        scope2 = self.playground.get_or_create_scope("scope2")
        workspace = self.playground.create_workspace(
            scope=scope1, name="workspace", public=True
        )
        self.artifact.workspace = workspace
        self.artifact.save()
        self.artifact_target.workspace = workspace
        self.artifact_target.save()
        artifact_relation = ArtifactRelation.objects.create(
            artifact=self.artifact,
            target=self.artifact_target,
            type=ArtifactRelation.Relations.RELATES_TO,
        )

        response = self.delete_relation(
            artifact_relation_id=artifact_relation.id,
            token=self.scenario.user_token,
            scope=scope2.name,
        )

        self.assertResponseProblem(
            response,
            "No ArtifactRelation matches the given query.",
            status_code=status.HTTP_404_NOT_FOUND,
        )

        response = self.delete_relation(
            artifact_relation_id=artifact_relation.id,
            token=self.scenario.user_token,
            scope=workspace.scope.name,
        )

        self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
        self.assertEqual(self.artifact.relations.all().count(), 0)

    def test_delete_private_workspace_unauthorized(self) -> None:
        """Deleting relations in private workspaces 404s to the unauthorized."""
        private_workspace = self.playground.create_workspace(name="Private")
        self.artifact.workspace = private_workspace
        self.artifact.save()
        self.artifact_target.workspace = private_workspace
        self.artifact_target.save()
        artifact_relation = ArtifactRelation.objects.create(
            artifact=self.artifact,
            target=self.artifact_target,
            type=ArtifactRelation.Relations.RELATES_TO,
        )

        response = self.delete_relation(
            artifact_relation_id=artifact_relation.id,
            token=self.scenario.user_token,
            scope=private_workspace.scope.name,
        )

        self.assertResponseProblem(
            response,
            "No ArtifactRelation matches the given query.",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_delete_private_workspace_authorized(self) -> None:
        """Deleting relations in private workspaces 200s to the authorized."""
        private_workspace = self.playground.create_workspace(name="Private")
        self.playground.create_group_role(
            private_workspace, Workspace.Roles.OWNER, self.scenario.user
        )
        self.artifact.workspace = private_workspace
        self.artifact.save()
        self.artifact_target.workspace = private_workspace
        self.artifact_target.save()
        artifact_relation = ArtifactRelation.objects.create(
            artifact=self.artifact,
            target=self.artifact_target,
            type=ArtifactRelation.Relations.RELATES_TO,
        )

        response = self.delete_relation(
            artifact_relation_id=artifact_relation.id,
            token=self.scenario.user_token,
            scope=private_workspace.scope.name,
        )

        self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
        self.assertEqual(self.artifact.relations.all().count(), 0)


class LookupSingleViewTests(TestCase):
    """Tests for LookupSingleView."""

    workspace: ClassVar[Workspace]
    worker: ClassVar[Worker]
    work_request: ClassVar[WorkRequest]

    @classmethod
    @context.disable_permission_checks()
    def setUpTestData(cls) -> None:
        """Set up common data for tests."""
        super().setUpTestData()
        cls.workspace = cls.playground.create_workspace(
            name="public", public=True
        )
        cls.worker = Worker.objects.create_with_fqdn(
            "worker-test", cls.playground.create_bare_token()
        )
        cls.work_request = cls.playground.create_work_request(
            worker=cls.worker,
            task_name="noop",
            workspace=cls.workspace,
        )

    def test_unauthenticated(self) -> None:
        """Authentication is required."""
        self.assertIn(
            IsWorkerAuthenticated, LookupSingleView.permission_classes
        )

        response = self.client.post(
            reverse("api:lookup-single"),
            data={
                "lookup": "",
                "work_request": self.work_request.id,
                "expect_type": LookupChildType.ARTIFACT,
            },
            content_type="application/json",
        )

        self.assertEqual(
            response.json(),
            {
                "title": "Error",
                "detail": "Authentication credentials were not provided.",
            },
        )
        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

    def test_work_request_not_found(self) -> None:
        """The work request must exist."""
        assert self.worker.token is not None
        response = self.client.post(
            reverse("api:lookup-single"),
            data={
                "lookup": "x",
                "work_request": self.work_request.id + 1,
                "expect_type": LookupChildType.ARTIFACT,
            },
            headers={"token": self.worker.token.key},
            content_type="application/json",
        )

        self.assertResponseProblem(
            response, "Error", detail_pattern="Invalid pk"
        )

    def test_unauthorized(self) -> None:
        """The work request must be assigned to this worker."""
        assert self.worker.token is not None
        another_worker = Worker.objects.create_with_fqdn(
            "another-worker-test", self.playground.create_bare_token()
        )
        another_work_request = self.playground.create_work_request(
            worker=another_worker, task_name="noop"
        )

        response = self.client.post(
            reverse("api:lookup-single"),
            data={
                "lookup": "x",
                "work_request": another_work_request.id,
                "expect_type": LookupChildType.ARTIFACT,
            },
            headers={"token": self.worker.token.key},
            content_type="application/json",
        )

        self.assertResponseProblem(
            response,
            f"Work request {another_work_request.id} is not assigned to the "
            f"authenticated worker",
            status_code=status.HTTP_401_UNAUTHORIZED,
        )

    def post_lookup(
        self,
        lookup: int | str,
        expect_type: LookupChildType,
        default_category: CollectionCategory | None = None,
    ) -> TestResponseType:
        """Make a lookup request."""
        data = {
            "lookup": lookup,
            "work_request": self.work_request.id,
            "expect_type": expect_type,
            "default_category": default_category,
        }
        assert self.worker.token is not None
        return self.client.post(
            reverse("api:lookup-single"),
            data=data,
            headers={"token": self.worker.token.key},
            content_type="application/json",
        )

    def test_success_string(self) -> None:
        """A string lookup succeeds and returns an artifact ID."""
        with context.disable_permission_checks():
            artifact = self.playground.create_artifact(
                workspace=self.workspace
            )[0]

        response = self.post_lookup(
            f"{artifact.id}@artifacts", LookupChildType.ARTIFACT
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(
            response.json(),
            {
                "result_type": LookupResultType.ARTIFACT,
                "collection_item": None,
                "artifact": artifact.id,
                "collection": None,
            },
        )

    def test_success_integer(self) -> None:
        """An integer lookup succeeds and returns an artifact ID."""
        with context.disable_permission_checks():
            artifact = self.playground.create_artifact(
                workspace=self.workspace
            )[0]

        response = self.post_lookup(artifact.id, LookupChildType.ARTIFACT)

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(
            response.json(),
            {
                "result_type": LookupResultType.ARTIFACT,
                "collection_item": None,
                "artifact": artifact.id,
                "collection": None,
            },
        )

    def test_key_error(self) -> None:
        """The view returns an error if the lookup returns no items."""
        response = self.post_lookup(
            "nonsense@artifacts", LookupChildType.ARTIFACT
        )

        self.assertResponseProblem(
            response,
            "No matches",
            detail_pattern="'nonsense@artifacts' does not exist or is hidden",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_lookup_error(self) -> None:
        """The view returns an error if the lookup is invalid."""
        response = self.post_lookup(
            "internal@collections", LookupChildType.ARTIFACT
        )

        self.assertResponseProblem(
            response,
            "Lookup error",
            detail_pattern=(
                "internal@collections is only valid in the context of a "
                "workflow"
            ),
            status_code=status.HTTP_400_BAD_REQUEST,
        )

    def test_wrong_workspace(self) -> None:
        """The item must be in a visible workspace."""
        with context.disable_permission_checks():
            workspace = self.playground.create_workspace(name="test")
            artifact = self.playground.create_artifact(workspace=workspace)[0]
        lookup = f"{artifact.id}@artifacts"

        response = self.post_lookup(lookup, LookupChildType.ARTIFACT)

        # FIXME: this was previously failing, but self.post_lookup does pass a
        #        valid worker token, and since #523 is none done yet, I
        #        understand it's really supposed to succeed?

        # self.assertResponseProblem(
        #     response,
        #     "No matches",
        #     detail_pattern=f"{lookup!r} does not exist or is hidden",
        #     status_code=status.HTTP_404_NOT_FOUND,
        # )
        self.assertEqual(
            response.json(),
            {
                "result_type": LookupResultType.ARTIFACT,
                "collection_item": None,
                "artifact": artifact.id,
                "collection": None,
            },
        )

    def test_wrong_type(self) -> None:
        """The item must be of the correct type."""
        with context.disable_permission_checks():
            artifact = self.playground.create_artifact(
                workspace=self.workspace
            )[0]
        lookup = f"{artifact.id}@artifacts"

        response = self.post_lookup(lookup, LookupChildType.COLLECTION)

        self.assertResponseProblem(
            response,
            "Lookup error",
            detail_pattern=f"{lookup!r} is of type 'artifact'"
            " instead of expected 'collection'",
            status_code=status.HTTP_400_BAD_REQUEST,
        )

    def test_default_category(self) -> None:
        """The client can request a default category."""
        collection = self.playground.create_collection(
            "collection", CollectionCategory.TEST, workspace=self.workspace
        )

        response = self.post_lookup("collection", LookupChildType.COLLECTION)

        self.assertResponseProblem(
            response,
            "Lookup error",
            detail_pattern=(
                "'collection' does not specify a category and the context "
                "does not supply a default"
            ),
            status_code=status.HTTP_400_BAD_REQUEST,
        )

        response = self.post_lookup(
            "collection",
            LookupChildType.COLLECTION,
            default_category=CollectionCategory.TEST,
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(
            response.json(),
            {
                "result_type": LookupResultType.COLLECTION,
                "collection_item": None,
                "artifact": None,
                "collection": collection.id,
            },
        )


class LookupMultipleViewTests(TestCase):
    """Tests for LookupMultipleView."""

    workspace: ClassVar[Workspace]
    worker: ClassVar[Worker]
    work_request: ClassVar[WorkRequest]

    @classmethod
    @context.disable_permission_checks()
    def setUpTestData(cls) -> None:
        """Set up common data for tests."""
        super().setUpTestData()
        cls.workspace = cls.playground.create_workspace(
            name="public", public=True
        )
        cls.worker = Worker.objects.create_with_fqdn(
            "worker-test", cls.playground.create_bare_token()
        )
        cls.work_request = cls.playground.create_work_request(
            worker=cls.worker, task_name="noop", workspace=cls.workspace
        )

    def test_unauthenticated(self) -> None:
        """Authentication is required."""
        self.assertIn(
            IsWorkerAuthenticated, LookupMultipleView.permission_classes
        )

        response = self.client.post(
            reverse("api:lookup-multiple"),
            data={
                "lookup": {"collection": "test"},
                "work_request": self.work_request.id,
                "expect_type": LookupChildType.ARTIFACT,
            },
            content_type="application/json",
        )

        self.assertEqual(
            response.json(),
            {
                "title": "Error",
                "detail": "Authentication credentials were not provided.",
            },
        )
        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

    def test_work_request_not_found(self) -> None:
        """The work request must exist."""
        assert self.worker.token is not None
        response = self.client.post(
            reverse("api:lookup-multiple"),
            data={
                "lookup": {"collection": "test"},
                "work_request": self.work_request.id + 1,
                "expect_type": LookupChildType.ARTIFACT,
            },
            headers={"token": self.worker.token.key},
            content_type="application/json",
        )

        self.assertResponseProblem(
            response, "Error", detail_pattern="Invalid pk"
        )

    def test_unauthorized(self) -> None:
        """The work request must be assigned to this worker."""
        assert self.worker.token is not None
        another_worker = Worker.objects.create_with_fqdn(
            "another-worker-test", self.playground.create_bare_token()
        )
        another_work_request = self.playground.create_work_request(
            worker=another_worker, task_name="noop"
        )

        response = self.client.post(
            reverse("api:lookup-multiple"),
            data={
                "lookup": {"collection": "test"},
                "work_request": another_work_request.id,
                "expect_type": LookupChildType.ARTIFACT,
            },
            headers={"token": self.worker.token.key},
            content_type="application/json",
        )

        self.assertResponseProblem(
            response,
            f"Work request {another_work_request.id} is not assigned to the "
            f"authenticated worker",
            status_code=status.HTTP_401_UNAUTHORIZED,
        )

    def post_lookup(
        self,
        lookup: dict[str, Any] | list[str | dict[str, Any]],
        expect_type: LookupChildType,
        default_category: CollectionCategory | None = None,
    ) -> TestResponseType:
        """Make a lookup request."""
        data = {
            "lookup": lookup,
            "work_request": self.work_request.id,
            "expect_type": expect_type,
            "default_category": default_category,
        }
        assert self.worker.token
        return self.client.post(
            reverse("api:lookup-multiple"),
            data=data,
            headers={"token": self.worker.token.key},
            content_type="application/json",
        )

    def test_success(self) -> None:
        """The lookup succeeds and returns an artifact ID."""
        with context.disable_permission_checks():
            collection = self.playground.create_collection(
                "collection", CollectionCategory.TEST, workspace=self.workspace
            )
            artifacts = [
                self.playground.create_artifact(category=ArtifactCategory.TEST)[
                    0
                ]
                for _ in range(2)
            ]
            collection_items = [
                CollectionItem.objects.create(
                    parent_collection=collection,
                    name=f"artifact{i}",
                    child_type=CollectionItem.Types.ARTIFACT,
                    category=ArtifactCategory.TEST,
                    artifact=artifact,
                    data={},
                    created_by_user=self.playground.get_default_user(),
                )
                for i, artifact in enumerate(artifacts)
            ]

        response = self.post_lookup(
            {"collection": f"collection@{CollectionCategory.TEST}"},
            LookupChildType.ARTIFACT,
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertCountEqual(
            response.json(),
            [
                {
                    "result_type": LookupResultType.ARTIFACT,
                    "collection_item": collection_item.id,
                    "artifact": artifact.id,
                    "collection": None,
                }
                for artifact, collection_item in zip(
                    artifacts, collection_items
                )
            ],
        )

    def test_normalized(self) -> None:
        """The view accepts fully-normalized lookups."""
        with context.disable_permission_checks():
            collection = self.playground.create_collection(
                "collection", CollectionCategory.TEST, workspace=self.workspace
            )
            artifacts = [
                self.playground.create_artifact(category=ArtifactCategory.TEST)[
                    0
                ]
                for _ in range(2)
            ]
            collection_items = [
                CollectionItem.objects.create(
                    parent_collection=collection,
                    name=f"artifact{i}",
                    child_type=CollectionItem.Types.ARTIFACT,
                    category=ArtifactCategory.TEST,
                    artifact=artifact,
                    data={},
                    created_by_user=self.playground.get_default_user(),
                )
                for i, artifact in enumerate(artifacts)
            ]

        lookup = LookupMultiple.parse_obj(
            {"collection": f"collection@{CollectionCategory.TEST}"}
        ).dict()["__root__"]
        self.assertEqual(
            lookup,
            (
                {
                    "category": None,
                    "child_type": "artifact",
                    "collection": f"collection@{CollectionCategory.TEST}",
                    "data_matchers": (),
                    "name_matcher": None,
                    "lookup_filters": (),
                },
            ),
        )

        response = self.post_lookup(lookup, LookupChildType.ARTIFACT)

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertCountEqual(
            response.json(),
            [
                {
                    "result_type": LookupResultType.ARTIFACT,
                    "collection_item": collection_item.id,
                    "artifact": artifact.id,
                    "collection": None,
                }
                for artifact, collection_item in zip(
                    artifacts, collection_items
                )
            ],
        )

    def test_deserialization_error(self) -> None:
        """The view returns an error if it cannot deserialize the lookup."""
        response = self.post_lookup(
            "nonsense", LookupChildType.ARTIFACT  # type: ignore[arg-type]
        )

        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
        self.assertEqual(
            response.headers["Content-Type"], "application/problem+json"
        )
        self.assertEqual(response.json()["title"], "Cannot deserialize lookup")
        self.assertRegex(
            response.json()["validation_errors"]["lookup"],
            r"^1 validation error for LookupMultiple",
        )

    def test_key_error(self) -> None:
        """The view returns an empty list if the lookup returns no items."""
        response = self.post_lookup(
            {"collection": "nonexistent@test"}, LookupChildType.ARTIFACT
        )

        self.assertResponseProblem(
            response,
            "One of the lookups returned no matches",
            detail_pattern="'nonexistent@test' does not exist or is hidden",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_lookup_error(self) -> None:
        """The view returns an error if the lookup is invalid."""
        response = self.post_lookup(
            {"collection": "internal@collections"},
            LookupChildType.ARTIFACT,
        )

        self.assertResponseProblem(
            response,
            "Lookup error",
            detail_pattern=(
                "internal@collections is only valid in the context of a "
                "workflow"
            ),
            status_code=status.HTTP_400_BAD_REQUEST,
        )

    def test_wrong_workspace(self) -> None:
        """The item must be in a visible workspace."""
        with context.disable_permission_checks():
            workspace = self.playground.create_workspace(name="test")
        self.playground.create_collection(
            "collection", CollectionCategory.TEST, workspace=workspace
        )

        response = self.post_lookup(
            [{"collection": f"collection@{CollectionCategory.TEST}"}],
            LookupChildType.ARTIFACT,
        )

        self.assertResponseProblem(
            response,
            "One of the lookups returned no matches",
            detail_pattern=(
                f"'collection@{CollectionCategory.TEST}' does not exist or is "
                f"hidden"
            ),
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_wrong_type(self) -> None:
        """The lookup must be of the correct type."""
        with context.disable_permission_checks():
            self.playground.create_collection(
                "collection", CollectionCategory.TEST, workspace=self.workspace
            )

        response = self.post_lookup(
            {
                "collection": f"collection@{CollectionCategory.TEST}",
                "child_type": "bare",
            },
            LookupChildType.COLLECTION,
        )

        self.assertResponseProblem(
            response,
            "Lookup error",
            detail_pattern=(
                "Only lookups for type 'collection' are allowed here"
            ),
            status_code=status.HTTP_400_BAD_REQUEST,
        )

    def test_default_category(self) -> None:
        """The client can request a default category."""
        with context.disable_permission_checks():
            collection = self.playground.create_collection(
                "collection", CollectionCategory.TEST, workspace=self.workspace
            )
            artifact = self.playground.create_artifact(
                category=ArtifactCategory.TEST
            )[0]
            collection_item = CollectionItem.objects.create(
                parent_collection=collection,
                name="artifact",
                child_type=CollectionItem.Types.ARTIFACT,
                category=ArtifactCategory.TEST,
                artifact=artifact,
                data={},
                created_by_user=self.playground.get_default_user(),
            )

        response = self.post_lookup(
            {"collection": "collection"}, LookupChildType.ARTIFACT
        )

        self.assertResponseProblem(
            response,
            "Lookup error",
            detail_pattern=(
                "'collection' does not specify a category and the context "
                "does not supply a default"
            ),
            status_code=status.HTTP_400_BAD_REQUEST,
        )

        response = self.post_lookup(
            {"collection": "collection"},
            LookupChildType.ARTIFACT,
            default_category=CollectionCategory.TEST,
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(
            response.json(),
            [
                {
                    "result_type": LookupResultType.ARTIFACT,
                    "collection_item": collection_item.id,
                    "artifact": artifact.id,
                    "collection": None,
                }
            ],
        )


class WorkflowTemplateViewTests(TestCase):
    """Tests for WorkflowTemplate."""

    scenario = scenarios.DefaultContextAPI()

    def setUp(self) -> None:
        """Set up common objects."""
        self.client = APIClient()
        self.token = self.scenario.user_token

    def get_workflow_template(
        self, workflow_template_id: int, scope: str | None = None
    ) -> HttpResponseBase:
        """Get a workflow template from api:workflow-template-detail."""
        headers = {"Token": self.token.key}
        if scope is not None:
            headers["X-Debusine-Scope"] = scope
        return self.client.get(
            reverse(
                "api:workflow-template-detail",
                kwargs={"pk": workflow_template_id},
            ),
            headers=headers,
        )

    def post_workflow_template(
        self, data: dict[str, Any], scope: str | None = None
    ) -> HttpResponseBase:
        """Post a workflow template to api:workflow-templates."""
        headers = {"Token": self.token.key}
        if scope is not None:
            headers["X-Debusine-Scope"] = scope
        return self.client.post(
            reverse("api:workflow-templates"),
            data=data,
            headers=headers,
            format="json",
        )

    def patch_workflow_template(
        self,
        workflow_template_id: int,
        data: dict[str, Any],
        scope: str | None = None,
    ) -> HttpResponseBase:
        """Patch a workflow template via api:workflow-template-detail."""
        headers = {"Token": self.token.key}
        if scope is not None:
            headers["X-Debusine-Scope"] = scope
        return self.client.patch(
            reverse(
                "api:workflow-template-detail",
                kwargs={"pk": workflow_template_id},
            ),
            data=data,
            headers=headers,
            format="json",
        )

    def delete_workflow_template(
        self, workflow_template_id: int, scope: str | None = None
    ) -> HttpResponseBase:
        """Delete a workflow template via api:workflow-template-detail."""
        headers = {"Token": self.token.key}
        if scope is not None:
            headers["X-Debusine-Scope"] = scope
        return self.client.delete(
            reverse(
                "api:workflow-template-detail",
                kwargs={"pk": workflow_template_id},
            ),
            headers=headers,
        )

    def test_authentication_credentials_not_provided(self) -> None:
        """A Token is required to use the endpoints."""
        response = self.client.get(reverse("api:workflow-templates"))
        self.assertResponseProblem(
            response,
            "Error",
            detail_pattern="Authentication credentials were not provided.",
            status_code=status.HTTP_403_FORBIDDEN,
        )

        response = self.client.get(
            reverse("api:workflow-template-detail", kwargs={"pk": 0})
        )
        self.assertResponseProblem(
            response,
            "Error",
            detail_pattern="Authentication credentials were not provided.",
            status_code=status.HTTP_403_FORBIDDEN,
        )

    def test_get_return_404_workflow_template_not_found(self) -> None:
        """Get a nonexistent workflow template: return 404."""
        response = self.get_workflow_template(0)

        self.assertResponseProblem(
            response,
            "Error",
            detail_pattern="No WorkflowTemplate matches the given query.",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_get_success(self) -> None:
        """Get a workflow template."""
        template = WorkflowTemplate.objects.create(
            name="test",
            workspace=default_workspace(),
            task_name="sbuild",
            task_data={"architectures": ["amd64", "arm64"]},
        )

        response = self.get_workflow_template(template.id)

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        assert isinstance(response, JSONResponseProtocol)
        self.assertEqual(
            response.json(),
            {
                "id": template.id,
                "name": template.name,
                "workspace": template.workspace.name,
                "task_name": template.task_name,
                "task_data": template.task_data,
                "priority": template.priority,
            },
        )

    def test_get_honours_scope(self) -> None:
        """Getting a workflow template looks it up in the current scope."""
        scope1 = self.playground.get_or_create_scope("scope1")
        scope2 = self.playground.get_or_create_scope("scope2")
        workspace = self.playground.create_workspace(
            scope=scope1, name="workspace", public=True
        )
        template = WorkflowTemplate.objects.create(
            name="test",
            workspace=workspace,
            task_name="sbuild",
            task_data={"architectures": ["amd64", "arm64"]},
        )

        response = self.get_workflow_template(
            template.id, scope=template.workspace.scope.name
        )
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        assert isinstance(response, JSONResponseProtocol)
        self.assertEqual(response.json()["workspace"], template.workspace.name)

        response = self.get_workflow_template(template.id, scope=scope2.name)

        self.assertResponseProblem(
            response,
            title="Error",
            detail_pattern=r"No WorkflowTemplate matches the given query\.",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_get_private_workspace_unauthorized(self) -> None:
        """Workflow templates in private workspaces 404 to the unauthorized."""
        private_workspace = self.playground.create_workspace(name="Private")
        template = WorkflowTemplate.objects.create(
            name="test",
            workspace=private_workspace,
            task_name="sbuild",
            task_data={"architectures": ["amd64", "arm64"]},
        )

        response = self.get_workflow_template(
            template.id, scope=private_workspace.scope.name
        )

        self.assertResponseProblem(
            response,
            title="Error",
            detail_pattern=r"No WorkflowTemplate matches the given query\.",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_get_private_workspace_authorized(self) -> None:
        """Workflow templates in private workspaces 200 to the authorized."""
        private_workspace = self.playground.create_workspace(name="Private")
        self.playground.create_group_role(
            private_workspace, Workspace.Roles.OWNER, self.scenario.user
        )
        template = WorkflowTemplate.objects.create(
            name="test",
            workspace=private_workspace,
            task_name="sbuild",
            task_data={"architectures": ["amd64", "arm64"]},
        )

        response = self.get_workflow_template(
            template.id, scope=private_workspace.scope.name
        )
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        assert isinstance(response, JSONResponseProtocol)
        self.assertEqual(response.json()["workspace"], template.workspace.name)

    def test_post_without_model_permissions(self) -> None:
        """Only privileged users may create workflow templates."""
        response = self.post_workflow_template(
            {
                "name": "test",
                "workspace": DEFAULT_WORKSPACE_NAME,
                "task_name": "noop",
            }
        )

        self.assertResponseProblem(
            response,
            "Error",
            detail_pattern="You do not have permission to perform this action.",
            status_code=status.HTTP_403_FORBIDDEN,
        )

    def test_post_success(self) -> None:
        """Create a new workflow template."""
        self.playground.add_user_permission(
            self.scenario.user, WorkflowTemplate, "add_workflowtemplate"
        )
        task_data = {
            "target_distribution": "debian:bookworm",
            "architectures": ["amd64", "arm64"],
        }

        response = self.post_workflow_template(
            {
                "name": "test",
                "workspace": DEFAULT_WORKSPACE_NAME,
                "task_name": "sbuild",
                "task_data": task_data,
                "priority": 0,
            }
        )

        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        template = WorkflowTemplate.objects.get(
            name="test", workspace=default_workspace()
        )
        self.assertEqual(template.task_name, "sbuild")
        self.assertEqual(template.task_data, task_data)

    def test_post_different_workspace(self) -> None:
        """Create a new workflow template in a non-default workspace."""
        self.playground.add_user_permission(
            self.scenario.user, WorkflowTemplate, "add_workflowtemplate"
        )
        with context.disable_permission_checks():
            workspace = self.playground.create_workspace(
                name="test-workspace", public=True
            )
        task_data = {"target_distribution": "debian:bookworm"}

        response = self.post_workflow_template(
            {
                "name": "test",
                "workspace": "test-workspace",
                "task_name": "sbuild",
                "task_data": task_data,
                "priority": 0,
            }
        )

        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        template = WorkflowTemplate.objects.get(
            name="test", workspace=workspace
        )
        self.assertEqual(template.task_name, "sbuild")
        self.assertEqual(template.task_data, task_data)

    def test_post_honours_scope(self) -> None:
        """Creating a workflow template looks up workspace in current scope."""
        self.playground.add_user_permission(
            self.scenario.user, WorkflowTemplate, "add_workflowtemplate"
        )
        scope1 = self.playground.get_or_create_scope("scope1")
        scope2 = self.playground.get_or_create_scope("scope2")
        scope3 = self.playground.get_or_create_scope("scope3")
        workspace1 = self.playground.create_workspace(
            scope=scope1, name="common-name", public=True
        )
        workspace2 = self.playground.create_workspace(
            scope=scope2, name="common-name", public=True
        )

        for workspace in (workspace1, workspace2):
            response = self.post_workflow_template(
                {
                    "name": "test",
                    "workspace": "common-name",
                    "task_name": "sbuild",
                    "priority": 0,
                },
                scope=workspace.scope.name,
            )

            self.assertEqual(response.status_code, status.HTTP_201_CREATED)
            assert isinstance(response, Response)
            template = WorkflowTemplate.objects.get(id=response.data["id"])
            self.assertEqual(template.workspace, workspace)

        response = self.post_workflow_template(
            {
                "name": "test",
                "workspace": "common-name",
                "task_name": "sbuild",
                "priority": 0,
            },
            scope=scope3.name,
        )
        self.assertResponseProblem(
            response,
            "Workspace not found",
            detail_pattern="Workspace common-name not found in scope scope3",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_post_no_default_workspace(self) -> None:
        """POST with no workspace in a scope without a default workspace."""
        self.playground.add_user_permission(
            self.scenario.user, WorkflowTemplate, "add_workflowtemplate"
        )
        scope = self.playground.get_or_create_scope("empty-scope")

        response = self.post_workflow_template(
            {"name": "test", "task_name": "sbuild", "priority": 0},
            scope=scope.name,
        )

        self.assertResponseProblem(
            response,
            "Cannot deserialize workflow template",
            validation_errors_pattern=(
                r"'workspace': \['This field is required\.'\]"
            ),
        )

    def test_post_private_workspace_unauthorized(self) -> None:
        """POST to private workspaces 404s to the unauthorized."""
        self.playground.add_user_permission(
            self.scenario.user, WorkflowTemplate, "add_workflowtemplate"
        )
        private_workspace = self.playground.create_workspace(name="Private")

        response = self.post_workflow_template(
            {
                "name": "test",
                "workspace": private_workspace.name,
                "task_name": "sbuild",
                "priority": 0,
            },
            scope=private_workspace.scope.name,
        )

        self.assertResponseProblem(
            response,
            "Workspace not found",
            detail_pattern=(
                f"Workspace {private_workspace.name} not found in scope "
                f"{private_workspace.scope.name}"
            ),
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_post_private_workspace_authorized(self) -> None:
        """POST to private workspaces succeeds for the authorized."""
        self.playground.add_user_permission(
            self.scenario.user, WorkflowTemplate, "add_workflowtemplate"
        )
        private_workspace = self.playground.create_workspace(name="Private")
        self.playground.create_group_role(
            private_workspace, Workspace.Roles.OWNER, self.scenario.user
        )

        response = self.post_workflow_template(
            {
                "name": "test",
                "workspace": private_workspace.name,
                "task_name": "sbuild",
                "priority": 0,
            },
            scope=private_workspace.scope.name,
        )

        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        assert isinstance(response, Response)
        template = WorkflowTemplate.objects.get(id=response.data["id"])
        self.assertEqual(template.workspace, private_workspace)

    def test_post_positive_priority_without_permissions(self) -> None:
        """Unprivileged users may not create with positive priorities."""
        self.playground.add_user_permission(
            self.scenario.user, WorkflowTemplate, "add_workflowtemplate"
        )

        response = self.post_workflow_template(
            {
                "name": "test",
                "workspace": DEFAULT_WORKSPACE_NAME,
                "task_name": "noop",
                "priority": 1,
            }
        )

        self.assertResponseProblem(
            response,
            "Error",
            detail_pattern="You are not permitted to set positive priorities",
            status_code=status.HTTP_403_FORBIDDEN,
        )

    def test_post_positive_priority_with_permissions(self) -> None:
        """Privileged users may create with positive priorities."""
        self.playground.add_user_permission(
            self.scenario.user, WorkflowTemplate, "add_workflowtemplate"
        )
        self.playground.add_user_permission(
            self.scenario.user, WorkRequest, "manage_workrequest_priorities"
        )

        response = self.post_workflow_template(
            {
                "name": "test",
                "workspace": DEFAULT_WORKSPACE_NAME,
                "task_name": "noop",
                "priority": 1,
            }
        )

        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        template = WorkflowTemplate.objects.get(
            name="test", workspace=default_workspace()
        )
        self.assertEqual(template.priority, 1)

    def test_patch_without_model_permissions(self) -> None:
        """Only privileged users may update workflow templates."""
        template = WorkflowTemplate.objects.create(
            name="test",
            workspace=default_workspace(),
            task_name="sbuild",
            task_data={"architectures": ["amd64"]},
        )

        response = self.patch_workflow_template(
            template.id, {"task_data": {"architectures": ["amd64", "arm64"]}}
        )

        self.assertResponseProblem(
            response,
            "Error",
            detail_pattern="You do not have permission to perform this action.",
            status_code=status.HTTP_403_FORBIDDEN,
        )

    def test_patch_success(self) -> None:
        """Update a workflow template."""
        template = WorkflowTemplate.objects.create(
            name="test",
            workspace=default_workspace(),
            task_name="sbuild",
            task_data={"architectures": ["amd64"]},
        )
        self.playground.add_user_permission(
            self.scenario.user, WorkflowTemplate, "change_workflowtemplate"
        )
        task_data = {"architectures": ["amd64", "arm64"]}

        response = self.patch_workflow_template(
            template.id, {"task_data": task_data}
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        template.refresh_from_db()
        self.assertEqual(template.task_data, task_data)

    def test_patch_positive_priority_without_permissions(self) -> None:
        """Unprivileged users may not update with positive priorities."""
        template = WorkflowTemplate.objects.create(
            name="test", workspace=default_workspace(), task_name="noop"
        )
        self.playground.add_user_permission(
            self.scenario.user, WorkflowTemplate, "change_workflowtemplate"
        )

        response = self.patch_workflow_template(template.id, {"priority": 1})

        self.assertResponseProblem(
            response,
            "Error",
            detail_pattern="You are not permitted to set positive priorities",
            status_code=status.HTTP_403_FORBIDDEN,
        )

    def test_patch_positive_priority_with_permissions(self) -> None:
        """Privileged users may update with positive priorities."""
        template = WorkflowTemplate.objects.create(
            name="test", workspace=default_workspace(), task_name="noop"
        )
        self.playground.add_user_permission(
            self.scenario.user, WorkflowTemplate, "change_workflowtemplate"
        )
        self.playground.add_user_permission(
            self.scenario.user, WorkRequest, "manage_workrequest_priorities"
        )

        response = self.patch_workflow_template(template.id, {"priority": 1})

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        template.refresh_from_db()
        self.assertEqual(template.priority, 1)

    def test_patch_honours_scope(self) -> None:
        """Patching a workflow template looks it up in the current scope."""
        scope1 = self.playground.get_or_create_scope("scope1")
        scope2 = self.playground.get_or_create_scope("scope2")
        workspace = self.playground.create_workspace(
            scope=scope1, name="workspace", public=True
        )
        template = WorkflowTemplate.objects.create(
            name="test",
            workspace=workspace,
            task_name="sbuild",
            task_data={"architectures": ["amd64"]},
        )
        self.playground.add_user_permission(
            self.scenario.user, WorkflowTemplate, "change_workflowtemplate"
        )
        task_data = {"architectures": ["amd64", "arm64"]}

        response = self.patch_workflow_template(
            template.id,
            {"task_data": task_data},
            scope=template.workspace.scope.name,
        )
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        template.refresh_from_db()
        self.assertEqual(template.task_data, task_data)

        response = self.patch_workflow_template(
            template.id, {"task_data": task_data}, scope=scope2.name
        )

        self.assertResponseProblem(
            response,
            title="Error",
            detail_pattern=r"No WorkflowTemplate matches the given query\.",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_patch_private_workspace_unauthorized(self) -> None:
        """Workflow templates in private workspaces 404 to the unauthorized."""
        private_workspace = self.playground.create_workspace(name="Private")
        template = WorkflowTemplate.objects.create(
            name="test",
            workspace=private_workspace,
            task_name="sbuild",
            task_data={"architectures": ["amd64"]},
        )
        self.playground.add_user_permission(
            self.scenario.user, WorkflowTemplate, "change_workflowtemplate"
        )
        task_data = {"architectures": ["amd64", "arm64"]}

        response = self.patch_workflow_template(
            template.id,
            {"task_data": task_data},
            scope=private_workspace.scope.name,
        )

        self.assertResponseProblem(
            response,
            title="Error",
            detail_pattern=r"No WorkflowTemplate matches the given query\.",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_patch_private_workspace_authorized(self) -> None:
        """Workflow templates in private workspaces 200 to the authorized."""
        private_workspace = self.playground.create_workspace(name="Private")
        self.playground.create_group_role(
            private_workspace, Workspace.Roles.OWNER, self.scenario.user
        )
        template = WorkflowTemplate.objects.create(
            name="test",
            workspace=private_workspace,
            task_name="sbuild",
            task_data={"architectures": ["amd64"]},
        )
        self.playground.add_user_permission(
            self.scenario.user, WorkflowTemplate, "change_workflowtemplate"
        )
        task_data = {"architectures": ["amd64", "arm64"]}

        response = self.patch_workflow_template(
            template.id,
            {"task_data": task_data},
            scope=private_workspace.scope.name,
        )
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        template.refresh_from_db()
        self.assertEqual(template.task_data, task_data)

    def test_delete_without_model_permissions(self) -> None:
        """Only privileged users may delete workflow templates."""
        template = WorkflowTemplate.objects.create(
            name="test", workspace=default_workspace(), task_name="noop"
        )

        response = self.delete_workflow_template(template.id)

        self.assertResponseProblem(
            response,
            "Error",
            detail_pattern="You do not have permission to perform this action.",
            status_code=status.HTTP_403_FORBIDDEN,
        )

    def test_delete_success(self) -> None:
        """Delete a workflow template."""
        template = WorkflowTemplate.objects.create(
            name="test", workspace=default_workspace(), task_name="noop"
        )
        self.playground.add_user_permission(
            self.scenario.user, WorkflowTemplate, "delete_workflowtemplate"
        )

        response = self.delete_workflow_template(template.id)

        self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
        with self.assertRaises(WorkflowTemplate.DoesNotExist):
            template.refresh_from_db()

    def test_delete_honours_scope(self) -> None:
        """Deleting a workflow template looks it up in the current scope."""
        scope1 = self.playground.get_or_create_scope("scope1")
        scope2 = self.playground.get_or_create_scope("scope2")
        workspace = self.playground.create_workspace(
            scope=scope1, name="workspace", public=True
        )
        template = WorkflowTemplate.objects.create(
            name="test", workspace=workspace, task_name="noop"
        )
        self.playground.add_user_permission(
            self.scenario.user, WorkflowTemplate, "delete_workflowtemplate"
        )

        response = self.get_workflow_template(template.id, scope=scope2.name)

        self.assertResponseProblem(
            response,
            title="Error",
            detail_pattern=r"No WorkflowTemplate matches the given query\.",
            status_code=status.HTTP_404_NOT_FOUND,
        )

        response = self.delete_workflow_template(
            template.id, scope=template.workspace.scope.name
        )
        self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
        with self.assertRaises(WorkflowTemplate.DoesNotExist):
            template.refresh_from_db()

    def test_delete_private_workspace_unauthorized(self) -> None:
        """Workflow templates in private workspaces 404 to the unauthorized."""
        private_workspace = self.playground.create_workspace(name="Private")
        template = WorkflowTemplate.objects.create(
            name="test", workspace=private_workspace, task_name="noop"
        )
        self.playground.add_user_permission(
            self.scenario.user, WorkflowTemplate, "delete_workflowtemplate"
        )

        response = self.delete_workflow_template(
            template.id, scope=private_workspace.scope.name
        )

        self.assertResponseProblem(
            response,
            title="Error",
            detail_pattern=r"No WorkflowTemplate matches the given query\.",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_delete_private_workspace_authorized(self) -> None:
        """Workflow templates in private workspaces 200 to the authorized."""
        private_workspace = self.playground.create_workspace(name="Private")
        self.playground.create_group_role(
            private_workspace, Workspace.Roles.OWNER, self.scenario.user
        )
        template = WorkflowTemplate.objects.create(
            name="test", workspace=private_workspace, task_name="noop"
        )
        self.playground.add_user_permission(
            self.scenario.user, WorkflowTemplate, "delete_workflowtemplate"
        )

        response = self.delete_workflow_template(
            template.id, scope=private_workspace.scope.name
        )
        self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
        with self.assertRaises(WorkflowTemplate.DoesNotExist):
            template.refresh_from_db()


class WorkflowViewTests(TestCase):
    """Tests for WorkflowView."""

    scenario = scenarios.DefaultContextAPI()

    def setUp(self) -> None:
        """Set up common objects."""
        super().setUp()
        self.client = APIClient()
        self.token = self.scenario.user_token

    def post_workflow(
        self, data: dict[str, Any], scope: str | None = None
    ) -> TestResponseType:
        """Post a workflow creation request to api:workflows."""
        headers = {"Token": self.token.key}
        if scope is not None:
            headers["X-Debusine-Scope"] = scope

        return self.client.post(
            reverse("api:workflows"), data=data, headers=headers, format="json"
        )

    def test_authentication_credentials_not_provided(self) -> None:
        """A Token is required to use the endpoint."""
        response = self.client.post(reverse("api:workflows"))
        self.assertResponseProblem(
            response,
            "Error",
            detail_pattern="Authentication credentials were not provided.",
            status_code=status.HTTP_403_FORBIDDEN,
        )

    def test_create_workflow_success(self) -> None:
        """Create a workflow."""
        artifact = self.playground.create_source_artifact()
        for architecture in ("amd64", "arm64"):
            self.playground.create_debian_environment(
                codename="bookworm", architecture=architecture
            )

        WorkflowTemplate.objects.create(
            name="test",
            workspace=default_workspace(),
            task_name="sbuild",
            task_data={"architectures": ["amd64", "arm64"]},
        )

        response = self.post_workflow(
            {
                "template_name": "test",
                "task_data": {
                    "input": {"source_artifact": artifact.id},
                    "target_distribution": "debian:bookworm",
                },
            }
        )

        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        workflow = WorkRequest.objects.latest("created_at")
        self.assertEqual(response.json(), WorkRequestSerializer(workflow).data)
        self.assertDictContainsAll(
            response.json(),
            {
                "workspace": DEFAULT_WORKSPACE_NAME,
                "created_by": self.token.user_id,
                "task_type": TaskTypes.WORKFLOW,
                "task_name": "sbuild",
                "task_data": {
                    "input": {"source_artifact": artifact.id},
                    "target_distribution": "debian:bookworm",
                    "architectures": ["amd64", "arm64"],
                },
                "priority_base": 0,
            },
        )

    def test_create_workflow_different_workspace(self) -> None:
        """Create a workflow in a non-default workspace."""
        workspace = self.playground.create_workspace(
            name="test-workspace", public=True
        )
        artifact = self.playground.create_source_artifact(workspace=workspace)
        for architecture in ("amd64", "arm64"):
            self.playground.create_debian_environment(
                workspace=workspace,
                codename="bookworm",
                architecture=architecture,
            )
        WorkflowTemplate.objects.create(
            name="test",
            workspace=workspace,
            task_name="sbuild",
            task_data={"architectures": ["amd64", "arm64"]},
        )

        response = self.post_workflow(
            {
                "template_name": "test",
                "workspace": workspace.name,
                "task_data": {
                    "input": {"source_artifact": artifact.id},
                    "target_distribution": "debian:bookworm",
                },
            }
        )

        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        workflow = WorkRequest.objects.latest("created_at")
        self.assertEqual(response.json(), WorkRequestSerializer(workflow).data)
        self.assertEqual(workflow.workspace, workspace)

    def test_create_workflow_nonexistent_template(self) -> None:
        """The view returns HTTP 404 if the workflow template does not exist."""
        response = self.post_workflow(
            {"template_name": "test", "task_data": {}}
        )

        self.assertResponseProblem(
            response,
            "Workflow template not found",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_create_workflow_invalid_task_data(self) -> None:
        """Creating a workflow validates task data."""
        self.playground.create_workflow_template(name="noop", task_name="noop")

        response = self.post_workflow(
            {"template_name": "noop", "task_data": {"nonexistent": True}}
        )

        self.assertResponseProblem(
            response,
            "Cannot create workflow",
            detail_pattern=(
                r"extra fields not permitted \(type=value_error\.extra\)"
            ),
        )

    def test_create_workflow_thorough_input_validation(self) -> None:
        """Creating a workflow does thorough validation of input data."""
        self.playground.create_workflow_template(
            name="sbuild", task_name="sbuild"
        )
        artifact, _ = self.playground.create_artifact(
            category=ArtifactCategory.TEST
        )

        response = self.post_workflow(
            {
                "template_name": "sbuild",
                "task_data": {
                    "input": {"source_artifact": artifact.id},
                    "target_distribution": "debian:bookworm",
                    "architectures": ["amd64"],
                },
            }
        )

        self.assertResponseProblem(
            response,
            "Cannot create workflow",
            detail_pattern=(
                r'Category of ".* debusine:test .*" is not one of '
                r"\['debian:source-package', 'debian:upload'\]"
            ),
        )

    def test_create_workflow_honours_scope(self) -> None:
        """Creating a workflow looks up workspace in current scope."""
        artifact = self.playground.create_source_artifact()
        for architecture in ("amd64", "i386"):
            self.playground.create_debian_environment(
                codename="bookworm", architecture=architecture
            )

        scope1 = self.playground.get_or_create_scope("scope1")
        scope2 = self.playground.get_or_create_scope("scope2")
        scope3 = self.playground.get_or_create_scope("scope3")
        workspace1 = self.playground.create_workspace(
            scope=scope1, name="common-name", public=True
        )
        workspace2 = self.playground.create_workspace(
            scope=scope2, name="common-name", public=True
        )
        for workspace, architectures in (
            (workspace1, ["amd64"]),
            (workspace2, ["i386"]),
        ):
            workspace.set_inheritance([self.scenario.workspace])
            self.playground.create_workflow_template(
                name="test",
                task_name="sbuild",
                workspace=workspace,
                task_data={"architectures": architectures},
            )
        task_data = {
            "input": {"source_artifact": artifact.id},
            "target_distribution": "debian:bookworm",
        }

        for workspace, architectures in (
            (workspace1, ["amd64"]),
            (workspace2, ["i386"]),
        ):
            response = self.post_workflow(
                {
                    "template_name": "test",
                    "workspace": "common-name",
                    "task_data": task_data,
                },
                scope=workspace.scope.name,
            )

            self.assertEqual(response.status_code, status.HTTP_201_CREATED)
            workflow = WorkRequest.objects.latest("created_at")
            self.assertEqual(
                response.json(), WorkRequestSerializer(workflow).data
            )
            self.assertEqual(workflow.workspace, workspace)
            self.assertEqual(workflow.task_data["architectures"], architectures)

        response = self.post_workflow(
            {
                "template_name": "test",
                "workspace": "common-name",
                "task_data": task_data,
            },
            scope=scope3.name,
        )
        self.assertResponseProblem(
            response,
            "Workspace not found",
            detail_pattern="Workspace common-name not found in scope scope3",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_create_workflow_no_default_workspace(self) -> None:
        """POST with no workspace in a scope without a default workspace."""
        scope = self.playground.get_or_create_scope("empty-scope")

        response = self.post_workflow(
            {"template_name": "test"}, scope=scope.name
        )

        self.assertResponseProblem(
            response,
            "Cannot deserialize workflow creation request",
            validation_errors_pattern=(
                r"'workspace': \['This field is required\.'\]"
            ),
        )

    def test_create_workflow_private_workspace_unauthorized(self) -> None:
        """POST to private workspaces 404s to the unauthorized."""
        private_workspace = self.playground.create_workspace(name="Private")
        self.playground.create_workflow_template(
            name="test", task_name="noop", workspace=private_workspace
        )

        response = self.post_workflow(
            {"template_name": "test", "workspace": private_workspace.name},
            scope=private_workspace.scope.name,
        )

        self.assertResponseProblem(
            response,
            "Workspace not found",
            detail_pattern=(
                f"Workspace {private_workspace.name} not found in scope "
                f"{private_workspace.scope.name}"
            ),
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_create_workflow_private_workspace_authorized(self) -> None:
        """POST to private workspaces succeeds for the authorized."""
        private_workspace = self.playground.create_workspace(name="Private")
        self.playground.create_group_role(
            private_workspace, Workspace.Roles.OWNER, self.scenario.user
        )
        self.playground.create_workflow_template(
            name="test", task_name="noop", workspace=private_workspace
        )

        response = self.post_workflow(
            {"template_name": "test", "workspace": private_workspace.name},
            scope=private_workspace.scope.name,
        )

        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        workflow = WorkRequest.objects.latest("created_at")
        self.assertEqual(response.json(), WorkRequestSerializer(workflow).data)
        self.assertEqual(workflow.workspace, private_workspace)
