# Copyright 2022-2024 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 artifact views."""

import abc
import io
import os.path
import tarfile
from datetime import timedelta
from pathlib import Path
from typing import Any, ClassVar, TYPE_CHECKING

import lxml
import yaml
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.db.models import Max
from django.db.models.functions import Lower
from django.http.response import HttpResponseBase
from django.urls import reverse
from django.utils.http import http_date
from rest_framework import status

from debusine.artifacts.models import ArtifactCategory
from debusine.db.models import (
    Artifact,
    ArtifactRelation,
    FileInArtifact,
    Token,
    User,
    default_workspace,
)
from debusine.server.file_backend.local import LocalFileBackend
from debusine.test.django import TestCase
from debusine.web.views import ui_shortcuts
from debusine.web.views.artifacts import DownloadPathView
from debusine.web.views.tests.utils import ViewTestMixin, date_format

if TYPE_CHECKING:
    from django.test.client import _MonkeyPatchedWSGIResponse


class PermissionTests(TestCase, abc.ABC):
    """Permission checks common to all other test cases."""

    # Note: this is deleted at the end of the file, to prevent it from being
    # run as a test case

    @abc.abstractmethod
    def permission_tests_get(
        self, *, include_token: bool, public_workspace: bool = False
    ):
        """Override to perform a get request to drive permission tests."""

    def test_check_permissions_denied(self):
        """Permission denied: no token, logged user or public workspace."""
        response = self.permission_tests_get(include_token=False)
        self.assertContains(
            response,
            DownloadPathView.permission_denied_message,
            status_code=status.HTTP_403_FORBIDDEN,
        )

    def test_check_permissions_valid_token_allowed(self):
        """Permission granted: valid token."""
        response = self.permission_tests_get(include_token=True)
        self.assertEqual(response.status_code, status.HTTP_200_OK)

    def test_check_permissions_public_workspace(self):
        """Permission granted: without a token but it is a public workspace."""
        response = self.permission_tests_get(
            include_token=False, public_workspace=True
        )
        self.assertEqual(response.status_code, status.HTTP_200_OK)

    def test_check_permission_logged_user(self):
        """Permission granted: user is logged in."""
        user = get_user_model().objects.create_user(
            username="testuser", password="testpassword"
        )
        self.client.force_login(user)

        response = self.permission_tests_get(include_token=False)
        self.assertEqual(response.status_code, status.HTTP_200_OK)


class ArtifactDetailViewTests(ViewTestMixin, PermissionTests):
    """Tests for the ArtifactDetailView class."""

    token: ClassVar[Token]
    path_in_artifact: ClassVar[str]
    file_size: ClassVar[int]
    artifact: ClassVar[Artifact]
    files_contents: ClassVar[dict[str, bytes]]

    @classmethod
    def setUpTestData(cls) -> None:
        """Set up the test fixture."""
        super().setUpTestData()
        cls.token = cls.playground.create_token_enabled()
        cls.path_in_artifact = "README.md"
        cls.file_size = 100
        cls.artifact, cls.files_contents = cls.playground.create_artifact(
            [cls.path_in_artifact, "AUTHORS"],
            files_size=cls.file_size,
            expiration_delay=1,
            create_files=True,
        )

    def permission_tests_get(
        self, *, include_token: bool, public_workspace: bool = False
    ):
        """Perform a get request to drive permission tests."""
        if public_workspace:
            self.assertTrue(self.artifact.workspace.public)
        else:
            self.artifact.workspace.public = False
            self.artifact.workspace.save()

        headers: dict[str, Any] = {}
        if include_token:
            headers["HTTP_TOKEN"] = self.token.key
        return self.client.get(
            reverse(
                "artifacts:detail", kwargs={"artifact_id": self.artifact.pk}
            ),
            **headers,
        )

    def _get(
        self,
        pk: int | None = None,
    ) -> "_MonkeyPatchedWSGIResponse":
        """GET request on the ArtifactDetail view."""
        if pk is None:
            pk = self.artifact.pk
        return self.client.get(
            reverse("artifacts:detail", kwargs={"artifact_id": pk}),
            headers={"token": self.token.key},
        )

    def assertArtifactSidebarMatches(
        self, response: "_MonkeyPatchedWSGIResponse", artifact: Artifact
    ) -> None:
        """Check the artifact sidebar contents."""
        sidebar = response.context["sidebar_items"]

        self.assertEqual(sidebar[0].detail, artifact.category)
        self.assertEqual(sidebar[1].value, artifact.workspace.name)

        if artifact.created_by_work_request:
            self.assertEqual(
                sidebar[2].url,
                reverse(
                    "work_requests:detail",
                    kwargs={"pk": artifact.created_by_work_request.pk},
                ),
            )
        else:
            self.assertIsNone(sidebar[2].value)

        if artifact.created_by:
            self.assertEqual(sidebar[3].value, str(artifact.created_by))
        else:
            self.assertIsNone(sidebar[3].value)

        self.assertEqual(
            sidebar[4].detail,
            date_format(artifact.created_at),
        )

        if artifact.expire_at:
            self.assertEqual(
                sidebar[5].detail,
                date_format(artifact.expire_at),
            )
        else:
            self.assertEqual(sidebar[5].value, "never")

    def assertFileList(
        self, tree: lxml.etree._Element, artifact: Artifact
    ) -> None:
        """Ensure there is a file list with all files in the artifact."""
        files = artifact.fileinartifact_set.select_related("file").order_by(
            Lower("path")
        )
        table = tree.xpath("//table[@id='file-list']")[0]

        for tr, file in zip(table.tbody.tr, files):
            with self.subTest(file.path):
                self.assertEqual(
                    tr.td[0].a.get("href"),
                    reverse(
                        "artifacts:detail-file",
                        kwargs={
                            "artifact_id": artifact.id,
                            "file_in_artifact_id": file.id,
                            "path": file.path,
                        },
                    ),
                )
                self.assertTextContentEqual(
                    tr.td[0].a, os.path.basename(file.path)
                )
                self.assertEqual(tr.td[1].get("title"), str(file.file.size))

    def assertRelations(
        self, tree: lxml.etree._Element, artifact: Artifact
    ) -> None:
        """Ensure that there is a relation list with all the relations."""
        relations = artifact.relations.all()
        targeted_by = artifact.targeted_by.filter(
            type=ArtifactRelation.Relations.EXTENDS
        )
        num_relations = len(relations) + len(targeted_by)
        tables = tree.xpath("//table[@id='relation-list']")
        if num_relations == 0:
            self.assertFalse(tables)
            return

        table = tables[0]
        self.assertEqual(len(table.tbody.tr), num_relations)
        for tr, relation in zip(table.tbody.tr, relations):
            with self.subTest(str(tr)):
                self.assertEqual(tr.td[0].text, relation.type)
                self.assertEqual(
                    tr.td[1].i.attrib["class"], "bi bi-arrow-right"
                )
                self.assertEqual(tr.td[3].a.text, relation.target.get_label())
        for tr, relation in zip(table.tbody.tr[len(relations) :], targeted_by):
            with self.subTest(str(tr)):
                self.assertEqual(tr.td[0].text, relation.type)
                self.assertEqual(tr.td[1].i.attrib["class"], "bi bi-arrow-left")
                self.assertEqual(tr.td[3].a.text, relation.artifact.get_label())

    def test_invalid_artifact_id(self) -> None:
        """Test viewing an artifact ID that does not exist."""
        artifact_id = Artifact.objects.aggregate(Max("id"))['id__max'] + 1
        response = self._get(pk=artifact_id)
        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
        self.assertEqual(
            response.context["exception"],
            "No artifact found matching the query",
        )

    def test_get_success_html_list(self) -> None:
        """View shows a list of files."""
        work_request = self.playground.create_work_request(task_name="noop")
        self.artifact.created_by_work_request = work_request
        self.artifact.save()
        response = self._get()
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertArtifactSidebarMatches(response, self.artifact)

        tree = self.assertHTMLValid(response)
        self.assertFileList(tree, self.artifact)
        self.assertRelations(tree, self.artifact)
        self.assertEqual(len(tree.xpath("//div[@id='file-contents']")), 0)

    def test_get_success_html_singlefile(self) -> None:
        """View show the content of the only file in the artifact."""
        self.artifact.fileinartifact_set.filter(path="AUTHORS").delete()
        response = self._get()
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertArtifactSidebarMatches(response, self.artifact)
        tree = self.assertHTMLValid(response)
        self.assertEqual(len(tree.xpath("//table[@id='file-list']")), 0)
        self.assertEqual(len(tree.xpath("//div[@id='file-contents']")), 1)

    def test_get_success_html_empty_artifact(self) -> None:
        """Test HTML output if there are no files in the artifact."""
        artifact, _ = self.create_artifact([])
        response = self._get(artifact.id)
        tree = self.assertHTMLValid(response)
        self.assertEqual(len(tree.xpath("//table[@id='file-list']")), 0)
        self.assertEqual(len(tree.xpath("//div[@id='file-contents']")), 0)
        no_files = tree.xpath("//p[@id='no-files']")[0]
        self.assertTextContentEqual(
            no_files, "The artifact does not have any files."
        )

    def test_get_success_html_user_and_no_expiration(self) -> None:
        """Test HTML output with no user and expiration."""
        self.artifact.created_by = self.playground.get_default_user()
        self.artifact.expiration_delay = timedelta(0)
        self.artifact.save()
        response = self._get()
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertArtifactSidebarMatches(response, self.artifact)

    def test_get_success_html_fwd_relations(self) -> None:
        """View shows a list of forward relations."""
        other_artifact = self.playground.create_build_log_artifact()
        self.playground.create_artifact_relation(self.artifact, other_artifact)
        response = self._get()
        tree = self.assertHTMLValid(response)
        self.assertRelations(tree, self.artifact)

    def test_get_success_html_rev_relations(self) -> None:
        """View shows a list of reverse relations."""
        other_artifact = self.playground.create_build_log_artifact()
        self.playground.create_artifact_relation(other_artifact, self.artifact)
        self.playground.create_artifact_relation(
            other_artifact,
            self.artifact,
            relation_type=ArtifactRelation.Relations.EXTENDS,
        )
        response = self._get()
        tree = self.assertHTMLValid(response)
        self.assertRelations(tree, self.artifact)

    def test_ui_shortcuts_source(self) -> None:
        """Check that UI shortcuts for source packages are as expected."""
        artifact = self.playground.create_source_artifact()
        response = self._get(pk=artifact.pk)
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(
            response.context["main_ui_shortcuts"],
            [
                ui_shortcuts.create_artifact_download(artifact),
            ],
        )

    def test_ui_shortcuts_source_with_work_request(self) -> None:
        """Check UI shortcuts for artifact with a work request."""
        work_request = self.playground.create_work_request(task_name="noop")
        artifact = self.playground.create_source_artifact()
        artifact.created_by_work_request = work_request
        artifact.save()
        response = self._get(pk=artifact.pk)
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(
            response.context["main_ui_shortcuts"],
            [
                ui_shortcuts.create_artifact_download(artifact),
            ],
        )

    def test_ui_shortcuts_build_log(self) -> None:
        """Check UI shortcuts for build logs."""
        artifact = self.playground.create_build_log_artifact()
        response = self._get(pk=artifact.pk)
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(
            response.context["main_ui_shortcuts"],
            [
                ui_shortcuts.create_artifact_download(artifact),
            ],
        )

    def test_ui_shortcuts_build_log_with_work_request(self) -> None:
        """Check UI shortcuts for build logs part of a work request."""
        work_request = self.playground.create_work_request(task_name="noop")
        artifact = self.playground.create_build_log_artifact(
            work_request=work_request
        )
        response = self._get(pk=artifact.pk)
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(
            response.context["main_ui_shortcuts"],
            [
                ui_shortcuts.create_artifact_download(artifact),
            ],
        )

    def test_ui_shortcuts_related_build_log(self) -> None:
        """Check UI shortcuts for build logs."""
        work_request = self.playground.create_work_request(task_name="noop")
        self.artifact.created_by_work_request = work_request
        self.artifact.save()
        build_log = self.playground.create_build_log_artifact(
            work_request=work_request
        )
        response = self._get()
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(
            response.context["main_ui_shortcuts"],
            [
                ui_shortcuts.create_artifact_view(build_log),
                ui_shortcuts.create_artifact_download(self.artifact),
            ],
        )

    def test_ui_shortcuts_multiple_build_log(self) -> None:
        """Check UI shortcuts for a work request with multiple build logs."""
        work_request = self.playground.create_work_request(task_name="noop")
        self.artifact.created_by_work_request = work_request
        self.artifact.save()
        # This can happen if the work request is retried.
        build_logs = [
            self.playground.create_build_log_artifact(work_request=work_request)
            for _ in range(2)
        ]
        response = self._get()
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(
            response.context["main_ui_shortcuts"],
            [
                ui_shortcuts.create_artifact_view(build_logs[-1]),
                ui_shortcuts.create_artifact_download(self.artifact),
            ],
        )


class ArtifactDetailFileViewTests(ViewTestMixin, PermissionTests):
    """Test ArtifactDetailFileView."""

    token: ClassVar[Token]
    contents: ClassVar[dict[str, bytes]]
    artifact: ClassVar[Artifact]
    file: ClassVar[FileInArtifact]

    @classmethod
    def setUpTestData(cls) -> None:
        """Set up the common test fixture."""
        super().setUpTestData()
        cls.token = cls.playground.create_token_enabled()
        cls.contents = {
            "file.md": b"Line 1\nLine 2\n",
        }
        cls.artifact, _ = cls.playground.create_artifact(
            paths=cls.contents,
            create_files=True,
        )
        cls.file = cls.artifact.fileinartifact_set.get(path="file.md")

    def permission_tests_get(
        self, *, include_token: bool, public_workspace: bool = False
    ):
        """Perform a get request to drive permission tests."""
        if public_workspace:
            self.assertTrue(self.artifact.workspace.public)
        else:
            self.artifact.workspace.public = False
            self.artifact.workspace.save()

        headers: dict[str, Any] = {}
        if include_token:
            headers["HTTP_TOKEN"] = self.token.key
        return self.client.get(
            reverse(
                "artifacts:detail-file",
                kwargs={
                    "artifact_id": self.artifact.id,
                    "file_in_artifact_id": self.file.id,
                    "path": self.file.path,
                },
            ),
            **headers,
        )

    def test_invalid_artifact_id(self):
        """Test viewing an artifact ID that does not exist."""
        artifact_id = Artifact.objects.aggregate(Max("id"))['id__max'] + 1
        response = self.client.get(
            reverse(
                "artifacts:detail-file",
                kwargs={
                    "artifact_id": artifact_id,
                    "file_in_artifact_id": self.file.id,
                    "path": self.file.path,
                },
            ),
            headers={"token": self.token.key},
        )
        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
        self.assertEqual(
            response.context["exception"],
            "No file in artifact found matching the query",
        )

    def test_invalid_file_in_artifact_id(self):
        """Test viewing a file_in_artifact ID that does not exist."""
        file_in_artifact_id = (
            FileInArtifact.objects.aggregate(Max("id"))['id__max'] + 1
        )
        response = self.client.get(
            reverse(
                "artifacts:detail-file",
                kwargs={
                    "artifact_id": self.artifact.id,
                    "file_in_artifact_id": file_in_artifact_id,
                    "path": self.file.path,
                },
            ),
            headers={"token": self.token.key},
        )
        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
        self.assertEqual(
            response.context["exception"],
            "No file in artifact found matching the query",
        )

    def test_invalid_file_in_artifact_path(self):
        """Test viewing a file_path that does not exist."""
        response = self.client.get(
            reverse(
                "artifacts:detail-file",
                kwargs={
                    "artifact_id": self.artifact.id,
                    "file_in_artifact_id": self.file.id,
                    "path": "invalid-path",
                },
            ),
            headers={"token": self.token.key},
        )
        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
        self.assertEqual(
            response.context["exception"],
            "No file in artifact found matching the query",
        )

    def test_get(self):
        """Test a simple get."""
        response = self.permission_tests_get(include_token=True)
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(
            response.context["main_ui_shortcuts"],
            [
                ui_shortcuts.create_file_view_raw(self.file),
                ui_shortcuts.create_file_download(self.file),
                ui_shortcuts.create_artifact_view(self.artifact),
            ],
        )
        self.assertIn("file", response.context)


class ArtifactDetailFileViewRawTests(ViewTestMixin, PermissionTests):
    """Test ArtifactDetailFileRawView."""

    playground_memory_file_store = False

    token: ClassVar[Token]
    contents: ClassVar[dict[str, bytes]]
    artifact: ClassVar[Artifact]
    file: ClassVar[FileInArtifact]

    @classmethod
    def setUpTestData(cls) -> None:
        """Set up the common test fixture."""
        super().setUpTestData()
        cls.token = cls.playground.create_token_enabled()
        cls.contents = {
            "file.md": b"Line 1\nLine 2\n",
        }
        cls.artifact, _ = cls.playground.create_artifact(
            paths=cls.contents,
            create_files=True,
        )
        cls.file = cls.artifact.fileinartifact_set.get(path="file.md")

    def permission_tests_get(
        self, *, include_token: bool, public_workspace: bool = False
    ):
        """Perform a get request to drive permission tests."""
        if public_workspace:
            self.assertTrue(self.artifact.workspace.public)
        else:
            self.artifact.workspace.public = False
            self.artifact.workspace.save()

        headers: dict[str, Any] = {}
        if include_token:
            headers["HTTP_TOKEN"] = self.token.key
        return self.client.get(
            reverse(
                "artifacts:detail-file-raw",
                kwargs={
                    "artifact_id": self.artifact.id,
                    "file_in_artifact_id": self.file.id,
                    "path": self.file.path,
                },
            ),
            **headers,
        )

    def test_invalid_artifact_id(self):
        """Test viewing an artifact ID that does not exist."""
        artifact_id = Artifact.objects.aggregate(Max("id"))['id__max'] + 1
        response = self.client.get(
            reverse(
                "artifacts:detail-file-raw",
                kwargs={
                    "artifact_id": artifact_id,
                    "file_in_artifact_id": self.file.id,
                    "path": self.file.path,
                },
            ),
            headers={"token": self.token.key},
        )
        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
        self.assertEqual(
            response.context["exception"],
            "No FileInArtifact matches the given query.",
        )

    def test_invalid_file_in_artifact_id(self):
        """Test viewing a file_in_artifact ID that does not exist."""
        file_in_artifact_id = (
            FileInArtifact.objects.aggregate(Max("id"))['id__max'] + 1
        )
        response = self.client.get(
            reverse(
                "artifacts:detail-file-raw",
                kwargs={
                    "artifact_id": self.artifact.id,
                    "file_in_artifact_id": file_in_artifact_id,
                    "path": self.file.path,
                },
            ),
            headers={"token": self.token.key},
        )
        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
        self.assertEqual(
            response.context["exception"],
            "No FileInArtifact matches the given query.",
        )

    def test_invalid_file_in_artifact_path(self):
        """Test viewing a file_path that does not exist."""
        response = self.client.get(
            reverse(
                "artifacts:detail-file-raw",
                kwargs={
                    "artifact_id": self.artifact.id,
                    "file_in_artifact_id": self.file.id,
                    "path": "invalid-path",
                },
            ),
            headers={"token": self.token.key},
        )
        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
        self.assertEqual(
            response.context["exception"],
            "No FileInArtifact matches the given query.",
        )

    def test_get(self):
        """Test a simple get."""
        response = self.permission_tests_get(include_token=True)
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(
            response.headers["Content-Disposition"],
            'inline; filename="file.md"',
        )


class DownloadPathViewTests(PermissionTests):
    """Tests for the DownloadPathView class."""

    playground_memory_file_store = False

    token: ClassVar[Token]
    path_in_artifact: ClassVar[str]
    file_size: ClassVar[int]
    tree_paths: ClassVar[list[str]]
    artifact: ClassVar[Artifact]
    tree: ClassVar[Artifact]
    files_contents: ClassVar[dict[str, bytes]]
    tree_files_contents: ClassVar[dict[str, bytes]]

    @classmethod
    def setUpTestData(cls) -> None:
        """Set up the test fixture."""
        super().setUpTestData()
        cls.token = cls.playground.create_token_enabled()
        cls.path_in_artifact = "README.md"
        cls.file_size = 100
        cls.artifact, cls.files_contents = cls.playground.create_artifact(
            [cls.path_in_artifact],
            files_size=cls.file_size,
            create_files=True,
        )
        cls.tree_paths = [
            "README",
            "doc/README",
            "doc/README2",
            "documentation",
            "src/lib/main.c",
            "src/lib/utils.c",
        ]
        cls.tree, cls.tree_files_contents = cls.playground.create_artifact(
            cls.tree_paths, create_files=True
        )

    def permission_tests_get(
        self, *, include_token: bool, public_workspace: bool = False
    ):
        """Perform a get request to drive permission tests."""
        if public_workspace:
            self.assertTrue(self.artifact.workspace.public)
        else:
            self.artifact.workspace.public = False
            self.artifact.workspace.save()

        headers: dict[str, Any] = {}
        if include_token:
            headers["HTTP_TOKEN"] = self.token.key
        return self.client.get(
            reverse(
                "artifacts:detail",
                kwargs={
                    "artifact_id": self.artifact.id,
                },
            ),
            **headers,
        )

    def get_file(
        self,
        *,
        artifact_id=None,
        path_file=None,
    ) -> HttpResponseBase:
        """
        Download file specified in the parameters.

        Unless specified: try to download the whole file (by default
        self.path_file and self.artifact.id).
        """
        if artifact_id is None:
            artifact_id = self.artifact.id

        if path_file is None:
            path_file = self.path_in_artifact

        return self.client.get(
            reverse(
                "artifacts:download-path",
                kwargs={
                    "artifact_id": artifact_id,
                    "path": path_file,
                },
            ),
            headers={"token": self.token.key},
        )

    def get_artifact(
        self,
        artifact_id: int,
        archive: str | None = None,
        subdirectory: str | None = None,
        **get_kwargs,
    ) -> HttpResponseBase:
        """Request to download an artifact_id."""
        reverse_kwargs: dict[str, Any] = {"artifact_id": artifact_id}
        viewname = "artifacts:download"

        if subdirectory is not None:
            viewname = "artifacts:download-path"
            reverse_kwargs["path"] = subdirectory

        if archive is not None:
            get_kwargs["archive"] = archive

        return self.client.get(
            reverse(viewname, kwargs=reverse_kwargs),
            get_kwargs,
            headers={"token": self.token.key},
        )

    def assertFileResponse(self, response, status_code):
        """Assert that response has the expected headers and content."""
        self.assertEqual(response.status_code, status_code)
        headers = response.headers

        self.assertEqual(headers["Accept-Ranges"], "bytes")

        file_contents = self.files_contents[self.path_in_artifact]
        response_contents = file_contents

        self.assertEqual(headers["Content-Length"], str(len(response_contents)))
        self.assertEqual(
            headers["Content-Range"],
            f"bytes {0}-{self.file_size - 1}/{self.file_size}",
        )

        filename = Path(self.path_in_artifact).name
        self.assertEqual(
            headers["Content-Disposition"], f'attachment; filename="{filename}"'
        )

        self.assertEqual(
            b"".join(response.streaming_content), response_contents
        )

    def assertResponseDownloadsTree(self, response) -> None:
        """Ensure response is a tar download of the self.tree artifact."""
        self.assertEqual(response.status_code, status.HTTP_200_OK)

        response_content = io.BytesIO(b"".join(response.streaming_content))

        tar = tarfile.open(fileobj=response_content, mode="r:gz")

        # Check contents of the tar file
        for path in self.tree_paths:
            reader = tar.extractfile(path)
            assert reader is not None
            self.assertEqual(reader.read(), self.tree_files_contents[path])

        # Check relevant headers
        self.assertEqual(
            response.headers["Content-Type"], "application/octet-stream"
        )
        self.assertEqual(
            response.headers["Content-Disposition"],
            f'attachment; filename="artifact-{self.tree.id}.tar.gz"',
        )
        self.assertEqual(
            response.headers["Last-Modified"],
            http_date(self.tree.created_at.timestamp()),
        )

    def test_path_url_does_not_end_in_slash(self):
        """
        URL to download a file does not end in /.

        If ending in / wget or curl -O save the file as index.html
        instead of using Content-Disposition filename.
        """
        url = reverse(
            "artifacts:download-path",
            kwargs={"artifact_id": 10, "path": "package.deb"},
        )
        self.assertFalse(url.endswith("/"))

    def test_get_file(self):
        """Get return the file."""
        response = self.get_file()
        self.assertFileResponse(response, status.HTTP_200_OK)
        self.assertEqual(
            response.headers["content-type"], "text/markdown; charset=utf-8"
        )

    def test_get_path_artifact_does_not_exist(self):
        """Get return 404: artifact not found."""
        non_existing_artifact_id = 0

        response = self.get_file(artifact_id=non_existing_artifact_id)

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

    def test_get_file_file_does_not_exist(self):
        """Get return 404: artifact found but file not found."""
        file_path_no_exist = "does-not-exist"

        response = self.get_file(path_file=file_path_no_exist)

        self.assertContains(
            response,
            f'Artifact {self.artifact.id} does not have '
            f'any file or directory for "{file_path_no_exist}"',
            status_code=status.HTTP_404_NOT_FOUND,
            html=True,
        )

    def test_get_subdirectory_does_not_exist_404(self):
        """View return HTTP 404 Not Found: no files in the subdirectory."""
        subdirectory = "does-not-exist"
        response = self.get_artifact(self.artifact.id, "tar.gz", subdirectory)

        self.assertContains(
            response,
            f'Artifact {self.artifact.id} does not have any file or '
            f'directory for "{subdirectory}"',
            status_code=status.HTTP_404_NOT_FOUND,
            html=True,
        )

    def test_get_subdirectory_only_tar_gz(self):
        """View return tar.gz file with the files from a subdirectory."""
        subdirectory = "src/lib"
        response = self.get_artifact(self.tree.id, "tar.gz", subdirectory)
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(
            response.headers["Content-Disposition"],
            f'attachment; filename="artifact-{self.tree.id}-src_lib.tar.gz"',
        )
        response_content = io.BytesIO(b"".join(response.streaming_content))

        tar = tarfile.open(fileobj=response_content, mode="r:gz")

        expected_files = list(
            filter(lambda x: x.startswith(subdirectory + "/"), self.tree_paths)
        )
        self.assertEqual(tar.getnames(), expected_files)

    def test_get_unsupported_archive_parameter(self):
        """View return HTTP 400 Bad Request: unsupported archive parameter."""
        archive_format = "tar.xz"
        response = self.get_artifact(self.artifact.id, archive_format)
        self.assertResponse400(
            response,
            f"Invalid archive parameter: '{archive_format}'. "
            "Supported: auto, tar.gz",
        )

    def test_path_without_archive(self):
        """Check downloading a path with a missing archive format."""
        response = self.get_artifact(
            self.tree.id, archive=None, subdirectory="src"
        )
        self.assertResponse400(
            response, "archive argument needed when downloading directories"
        )

    def test_get_artifact_auto_file(self):
        """Check downloading whole artifact with auto download format."""
        response = self.get_artifact(self.artifact.id, archive=None)
        self.assertFileResponse(response, status.HTTP_200_OK)
        self.assertEqual(
            response.headers["content-type"], "text/markdown; charset=utf-8"
        )

    def test_get_artifact_auto_tree(self):
        """Check downloading whole artifact with auto download format."""
        response = self.get_artifact(self.tree.id, archive=None)
        self.assertResponseDownloadsTree(response)

    def test_get_artifact_tar_gz(self):
        """Download a whole artifact as .tar.gz."""
        response = self.get_artifact(self.tree.id, "tar.gz")
        self.assertResponseDownloadsTree(response)


class CreateArtifactViewTests(TestCase):
    """Tests for CreateArtifactView."""

    playground_memory_file_store = False
    user: ClassVar[User]

    @classmethod
    def setUpTestData(cls):
        """Set up test data."""
        super().setUpTestData()
        cls.user = get_user_model().objects.create_user(
            username="testuser", password="testpassword"
        )

    def verify_create_artifact_with_files(
        self, files: list[SimpleUploadedFile]
    ):
        """
        Test CreateArtifactView via POST to downloads_artifact:create.

        Post the files to create an artifact and verify the created artifact
        and file upload.
        """
        self.client.force_login(self.user)

        # Create a dummy file for testing
        workspace = default_workspace()
        category = ArtifactCategory.WORK_REQUEST_DEBUG_LOGS

        files_to_upload: SimpleUploadedFile | list[SimpleUploadedFile]
        if len(files) == 1:
            files_to_upload = files[0]
        else:
            files_to_upload = files

        post_data = {
            "category": category,
            "workspace": workspace.id,
            "files": files_to_upload,
            "data": "",
        }

        response = self.client.post(reverse("artifacts:create"), post_data)
        self.assertEqual(response.status_code, 302)

        artifact = Artifact.objects.latest("id")

        self.assertRedirects(
            response,
            reverse(
                "artifacts:detail",
                kwargs={"artifact_id": artifact.id},
            ),
        )

        # Verify artifact
        self.assertEqual(artifact.created_by, self.user)
        self.assertEqual(artifact.workspace, workspace)
        self.assertEqual(artifact.category, category)
        self.assertEqual(artifact.data, {})

        # Verify uploaded files
        self.assertEqual(artifact.fileinartifact_set.count(), len(files))

        local_file_backend = LocalFileBackend(workspace.default_file_store)

        for file_in_artifact, file_to_upload in zip(
            artifact.fileinartifact_set.all().order_by("id"), files
        ):
            with local_file_backend.get_stream(file_in_artifact.file) as file:
                assert file_to_upload.file is not None
                file_to_upload.file.seek(0)
                content = file_to_upload.file.read()
                self.assertEqual(file.read(), content)
                self.assertEqual(file_in_artifact.path, file_to_upload.name)

            self.assertEqual(file_in_artifact.path, file_to_upload.name)

    def test_create_artifact_one_file(self):
        """Post to "user:artifact-create" to create an artifact: one file."""
        file = SimpleUploadedFile("testfile.txt", b"some_file_content")
        self.verify_create_artifact_with_files([file])

    def test_create_artifact_two_files(self):
        """Post to "user:artifact-create" to create an artifact: two files."""
        files = [
            SimpleUploadedFile("testfile.txt", b"some_file_content"),
            SimpleUploadedFile("testfile2.txt", b"another_file_content"),
        ]
        self.verify_create_artifact_with_files(files)

    def test_create_work_request_permission_denied(self):
        """A non-authenticated request cannot get the form (or post)."""
        for method in [self.client.get, self.client.post]:
            with self.subTest(method):
                response = method(reverse("artifacts:create"))
                self.assertContains(
                    response,
                    "You need to be authenticated to create an Artifact",
                    status_code=status.HTTP_403_FORBIDDEN,
                )

    def test_invalid_form_data(self) -> None:
        """Invalid form data returns an error."""
        self.client.force_login(self.user)
        post_data = {
            "category": ArtifactCategory.PACKAGE_BUILD_LOG,
            "workspace": default_workspace().id,
            "files": [
                SimpleUploadedFile("testfile.txt", b"some_file_content"),
                SimpleUploadedFile("testfile2.txt", b"another_file_content"),
            ],
            "data": yaml.dump(
                {
                    "source": "hello",
                    "version": "1.0-1",
                    "filename": "testfile.txt",
                }
            ),
        }

        response = self.client.post(reverse("artifacts:create"), post_data)
        self.assertContains(
            response,
            "Expected number of files: 1 Actual: 2",
            # HTTP 200 seems dubious, but it's apparently how Django forms
            # behave: https://code.djangoproject.com/ticket/22591
            status_code=status.HTTP_200_OK,
        )


del PermissionTests
