From 925007853bb045bb383c6ce3a7e4f5035645eb43 Mon Sep 17 00:00:00 2001
From: Oliver <20188437+olivergrabinski@users.noreply.github.com>
Date: Thu, 2 Nov 2023 15:33:32 +0100
Subject: [PATCH 01/22] Add FileUndeprecated event

---
 .../delta/plugins/storage/files/Files.scala   |  5 +++
 .../storage/files/model/FileEvent.scala       | 31 +++++++++++++++++++
 .../files/database/file-undeprecated.json     | 14 +++++++++
 .../files/sse/file-undeprecated.json          | 13 ++++++++
 .../files/model/FileSerializationSuite.scala  |  7 +++++
 5 files changed, 70 insertions(+)
 create mode 100644 delta/plugins/storage/src/test/resources/files/database/file-undeprecated.json
 create mode 100644 delta/plugins/storage/src/test/resources/files/sse/file-undeprecated.json

diff --git a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/Files.scala b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/Files.scala
index 88247825ba..afd88d512e 100644
--- a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/Files.scala
+++ b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/Files.scala
@@ -604,6 +604,10 @@ object Files {
       s.copy(rev = e.rev, deprecated = true, updatedAt = e.instant, updatedBy = e.subject)
     }
 
+    def undeprecated(e: FileUndeprecated): Option[FileState] = state.map { s =>
+      s.copy(rev = e.rev, deprecated = false, updatedAt = e.instant, updatedBy = e.subject)
+    }
+
     event match {
       case e: FileCreated           => created(e)
       case e: FileUpdated           => updated(e)
@@ -611,6 +615,7 @@ object Files {
       case e: FileTagAdded          => tagAdded(e)
       case e: FileTagDeleted        => tagDeleted(e)
       case e: FileDeprecated        => deprecated(e)
+      case e: FileUndeprecated      => undeprecated(e)
     }
   }
 
diff --git a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileEvent.scala b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileEvent.scala
index a059cd94d6..d52abb51cf 100644
--- a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileEvent.scala
+++ b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileEvent.scala
@@ -262,6 +262,34 @@ object FileEvent {
       subject: Subject
   ) extends FileEvent
 
+  /**
+    * Event for the undeprecation of a file
+    *
+    * @param id
+    *   the file identifier
+    * @param project
+    *   the project the file belongs to
+    * @param storage
+    *   the reference to the used storage
+    * @param storageType
+    *   the type of storage
+    * @param rev
+    *   the last known revision of the file
+    * @param instant
+    *   the instant this event was created
+    * @param subject
+    *   the subject creating this event
+    */
+  final case class FileUndeprecated(
+      id: Iri,
+      project: ProjectRef,
+      storage: ResourceRef.Revision,
+      storageType: StorageType,
+      rev: Int,
+      instant: Instant,
+      subject: Subject
+  ) extends FileEvent
+
   @nowarn("cat=unused")
   val serializer: Serializer[Iri, FileEvent] = {
     import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.Database._
@@ -291,6 +319,7 @@ object FileEvent {
             case _: FileTagAdded          => Tagged
             case _: FileTagDeleted        => TagDeleted
             case _: FileDeprecated        => Deprecated
+            case _: FileUndeprecated      => Undeprecated
           },
           event.id,
           Set(nxvFile),
@@ -427,6 +456,8 @@ object FileEvent {
           FileExtraFields(ftd.storage.iri, ftd.storageType, None, None, None, None)
         case fd: FileDeprecated                             =>
           FileExtraFields(fd.storage.iri, fd.storageType, None, None, None, None)
+        case fud: FileUndeprecated                          =>
+          FileExtraFields(fud.storage.iri, fud.storageType, None, None, None, None)
       }
 
     implicit val fileExtraFieldsEncoder: Encoder.AsObject[FileExtraFields] =
diff --git a/delta/plugins/storage/src/test/resources/files/database/file-undeprecated.json b/delta/plugins/storage/src/test/resources/files/database/file-undeprecated.json
new file mode 100644
index 0000000000..c4d08f704d
--- /dev/null
+++ b/delta/plugins/storage/src/test/resources/files/database/file-undeprecated.json
@@ -0,0 +1,14 @@
+{
+  "id" : "https://bluebrain.github.io/nexus/vocabulary/file",
+  "project" : "myorg/myproj",
+  "storage": "https://bluebrain.github.io/nexus/vocabulary/disk-storage?rev=1",
+  "storageType": "DiskStorage",
+  "rev" : 6,
+  "instant" : "1970-01-01T00:00:00Z",
+  "subject" : {
+    "subject" : "username",
+    "realm" : "myrealm",
+    "@type" : "User"
+  },
+  "@type" : "FileUndeprecated"
+}
diff --git a/delta/plugins/storage/src/test/resources/files/sse/file-undeprecated.json b/delta/plugins/storage/src/test/resources/files/sse/file-undeprecated.json
new file mode 100644
index 0000000000..2be9bdfafb
--- /dev/null
+++ b/delta/plugins/storage/src/test/resources/files/sse/file-undeprecated.json
@@ -0,0 +1,13 @@
+{
+  "@context": [
+    "https://bluebrain.github.io/nexus/contexts/metadata.json",
+    "https://bluebrain.github.io/nexus/contexts/files.json"
+  ],
+  "@type": "FileUndeprecated",
+  "_fileId": "https://bluebrain.github.io/nexus/vocabulary/file",
+  "_instant": "1970-01-01T00:00:00Z",
+  "_project": "http://localhost/v1/projects/myorg/myproj",
+  "_resourceId": "https://bluebrain.github.io/nexus/vocabulary/file",
+  "_rev": 6,
+  "_subject": "http://localhost/v1/realms/myrealm/users/username"
+}
\ No newline at end of file
diff --git a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileSerializationSuite.scala b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileSerializationSuite.scala
index 545d94cc4a..3882fd09b2 100644
--- a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileSerializationSuite.scala
+++ b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileSerializationSuite.scala
@@ -55,6 +55,7 @@ class FileSerializationSuite extends SerializationSuite with StorageFixtures {
   private val tagged = FileTagAdded(fileId, projectRef, storageRef, DiskStorageType, targetRev = 1, tag, 4, instant, subject)
   private val tagDeleted = FileTagDeleted(fileId, projectRef, storageRef, DiskStorageType, tag, 4, instant, subject)
   private val deprecated = FileDeprecated(fileId, projectRef, storageRef, DiskStorageType, 5, instant, subject)
+  private val undeprecated = FileUndeprecated(fileId, projectRef, storageRef, DiskStorageType, 6, instant, subject)
   // format: on
 
   private def expected(event: FileEvent, newFileWritten: Json, bytes: Json, mediaType: Json, origin: Json) =
@@ -121,6 +122,12 @@ class FileSerializationSuite extends SerializationSuite with StorageFixtures {
       loadEvents("files", "file-deprecated.json"),
       Deprecated,
       expected(deprecated, Json.Null, Json.Null, Json.Null, Json.Null)
+    ),
+    (
+      undeprecated,
+      loadEvents("files", "file-undeprecated.json"),
+      Undeprecated,
+      expected(undeprecated, Json.Null, Json.Null, Json.Null, Json.Null)
     )
   )
 

From ab7c5f047abd167f6f9ef4e4e8a8e493f1e71fbd Mon Sep 17 00:00:00 2001
From: Oliver <20188437+olivergrabinski@users.noreply.github.com>
Date: Fri, 3 Nov 2023 14:51:14 +0100
Subject: [PATCH 02/22] Add method to undeprecate files

---
 .../delta/plugins/storage/files/Files.scala   | 29 ++++++++++
 .../storage/files/model/FileCommand.scala     | 14 +++++
 .../storage/files/model/FileRejection.scala   |  8 +++
 .../plugins/storage/files/FilesSpec.scala     | 53 ++++++++++++++++++-
 4 files changed, 103 insertions(+), 1 deletion(-)

diff --git a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/Files.scala b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/Files.scala
index afd88d512e..ded1201861 100644
--- a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/Files.scala
+++ b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/Files.scala
@@ -334,6 +334,26 @@ final class Files(
     } yield res
   }.span("deprecateFile")
 
+  /**
+    * Undeprecate an existing file
+    *
+    * @param id
+    *   the file identifier to expand as the iri of the file
+    * @param projectRef
+    *   the project where the file belongs
+    * @param rev
+    *   the current revision of the file
+    */
+  def undeprecate(
+      id: FileId,
+      rev: Int
+  )(implicit subject: Subject): IO[FileResource] = {
+    for {
+      (iri, _) <- id.expandIri(fetchContext.onModify)
+      res      <- eval(UndeprecateFile(iri, id.project, rev, subject))
+    } yield res
+  }.span("undeprecateFile")
+
   /**
     * Fetch the last version of a file content
     *
@@ -683,6 +703,14 @@ object Files {
         IOInstant.now.map(FileDeprecated(c.id, c.project, s.storage, s.storageType, s.rev + 1, _, c.subject))
     }
 
+    def undeprecate(c: UndeprecateFile) = state match {
+      case None                      => IO.raiseError(FileNotFound(c.id, c.project))
+      case Some(s) if s.rev != c.rev => IO.raiseError(IncorrectRev(c.rev, s.rev))
+      case Some(s) if !s.deprecated  => IO.raiseError(FileIsNotDeprecated(c.id))
+      case Some(s)                   =>
+        IOInstant.now.map(FileUndeprecated(c.id, c.project, s.storage, s.storageType, s.rev + 1, _, c.subject))
+    }
+
     cmd match {
       case c: CreateFile           => create(c)
       case c: UpdateFile           => update(c)
@@ -690,6 +718,7 @@ object Files {
       case c: TagFile              => tag(c)
       case c: DeleteFileTag        => deleteTag(c)
       case c: DeprecateFile        => deprecate(c)
+      case c: UndeprecateFile      => undeprecate(c)
     }
   }
 
diff --git a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileCommand.scala b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileCommand.scala
index d3c9d4962c..24c383c02c 100644
--- a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileCommand.scala
+++ b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileCommand.scala
@@ -176,4 +176,18 @@ object FileCommand {
     *   the identity associated to this command
     */
   final case class DeprecateFile(id: Iri, project: ProjectRef, rev: Int, subject: Subject) extends FileCommand
+
+  /**
+    * Command to undeprecate a file
+    *
+    * @param id
+    *   the file identifier
+    * @param project
+    *   the project the file belongs to
+    * @param rev
+    *   the last known revision of the file
+    * @param subject
+    *   the identity associated to this command
+    */
+  final case class UndeprecateFile(id: Iri, project: ProjectRef, rev: Int, subject: Subject) extends FileCommand
 }
diff --git a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileRejection.scala b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileRejection.scala
index dbcf38d430..1763ee67fb 100644
--- a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileRejection.scala
+++ b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/model/FileRejection.scala
@@ -130,6 +130,14 @@ object FileRejection {
     */
   final case class FileIsDeprecated(id: Iri) extends FileRejection(s"File '$id' is deprecated.")
 
+  /**
+    * Rejection returned when attempting to undeprecate a file that is already deprecated.
+    *
+    * @param id
+    *   the file identifier
+    */
+  final case class FileIsNotDeprecated(id: Iri) extends FileRejection(s"File '$id' is not deprecated.")
+
   /**
     * Rejection returned when attempting to link a file without providing a filename or a path that ends with a
     * filename.
diff --git a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/FilesSpec.scala b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/FilesSpec.scala
index e4ddd7f032..d6db5714d5 100644
--- a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/FilesSpec.scala
+++ b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/FilesSpec.scala
@@ -41,7 +41,7 @@ import ch.epfl.bluebrain.nexus.delta.sourcing.postgres.DoobieScalaTestFixture
 import ch.epfl.bluebrain.nexus.testkit.remotestorage.RemoteStorageDocker
 import ch.epfl.bluebrain.nexus.testkit.scalatest.ce.CatsEffectSpec
 import monix.execution.Scheduler
-import org.scalatest.DoNotDiscover
+import org.scalatest.{Assertion, DoNotDiscover}
 import org.scalatest.concurrent.Eventually
 
 import java.net.URLDecoder
@@ -490,6 +490,41 @@ class FilesSpec(docker: RemoteStorageDocker)
 
     }
 
+    "undeprecating a file" should {
+
+      "succeed" in {
+        givenADeprecatedFile { id =>
+          files.undeprecate(id, 2).accepted.deprecated shouldEqual false
+        }
+      }
+
+      "reject if file doesn't exists" in {
+        files.undeprecate(fileId("404"), 1).rejectedWith[FileNotFound]
+      }
+
+      "reject if file is not deprecated" in {
+        givenAFile { id =>
+          files.undeprecate(id, 1).assertRejectedWith[FileIsNotDeprecated]
+        }
+      }
+
+      "reject if the revision passed is incorrect" in {
+        givenADeprecatedFile { id =>
+          files.undeprecate(id, 3).assertRejectedEquals(IncorrectRev(3, 2))
+        }
+      }
+
+      "reject if project does not exist" in {
+        val wrongProject = ProjectRef(org, Label.unsafe("other"))
+        files.deprecate(FileId(nxv + "id", wrongProject), 1).rejectedWith[ProjectContextRejection]
+      }
+
+      "reject if project is deprecated" in {
+        files.undeprecate(FileId(nxv + "id", deprecatedProject.ref), 2).rejectedWith[ProjectContextRejection]
+      }
+
+    }
+
     "fetching a file" should {
       val resourceRev1 = mkResource(file1, projectRef, diskRev, attributes("myfile.txt"))
       val resourceRev4 = mkResource(file1, projectRef, diskRev, attributes(), rev = 4)
@@ -583,6 +618,22 @@ class FilesSpec(docker: RemoteStorageDocker)
       }
 
     }
+
+    def givenAFile(assertion: FileId => Assertion): Assertion = {
+      val filename = genString()
+      val id       = fileId(filename)
+      files.create(id, Some(diskId), randomEntity(filename, 1), None).accepted
+      files.fetch(id).accepted
+      assertion(id)
+    }
+
+    def givenADeprecatedFile(assertion: FileId => Assertion): Assertion = {
+      givenAFile { id =>
+        files.deprecate(id, 1).accepted
+        files.fetch(id).accepted.deprecated shouldEqual true
+        assertion(id)
+      }
+    }
   }
 
 }

From 3e6a84710f5363357be9f80121e8638762af591c Mon Sep 17 00:00:00 2001
From: Oliver <20188437+olivergrabinski@users.noreply.github.com>
Date: Fri, 3 Nov 2023 14:55:58 +0100
Subject: [PATCH 03/22] Improve `.accepted`

Co-authored-by: Daniel Bell <shinyhappydan@users.noreply.github.com>
---
 .../nexus/testkit/scalatest/ce/CatsIOValues.scala         | 8 ++++++--
 1 file changed, 6 insertions(+), 2 deletions(-)

diff --git a/delta/testkit/src/main/scala/ch/epfl/bluebrain/nexus/testkit/scalatest/ce/CatsIOValues.scala b/delta/testkit/src/main/scala/ch/epfl/bluebrain/nexus/testkit/scalatest/ce/CatsIOValues.scala
index d6fcb2bd43..86992ce627 100644
--- a/delta/testkit/src/main/scala/ch/epfl/bluebrain/nexus/testkit/scalatest/ce/CatsIOValues.scala
+++ b/delta/testkit/src/main/scala/ch/epfl/bluebrain/nexus/testkit/scalatest/ce/CatsIOValues.scala
@@ -12,8 +12,12 @@ trait CatsIOValues {
   self: Suite =>
 
   implicit final class CatsIOValuesOps[A](private val io: IO[A]) {
-    def accepted: A =
-      io.unsafeRunTimed(45.seconds).getOrElse(fail("IO timed out during .accepted call"))
+    def accepted(implicit pos: source.Position): A = {
+      io.attempt.unsafeRunTimed(45.seconds).getOrElse(fail("IO timed out during .accepted call")) match {
+        case Left(e) => fail(s"IO failed when it was expected to succeed. Failure details: $e")
+        case Right(value) => value
+      }
+    }
 
     def rejected(implicit pos: source.Position): Throwable = rejectedWith[Throwable]
 

From bfe30b6e9f9f97113d60d0a10e5de246ad3d1846 Mon Sep 17 00:00:00 2001
From: Oliver <20188437+olivergrabinski@users.noreply.github.com>
Date: Fri, 3 Nov 2023 15:06:19 +0100
Subject: [PATCH 04/22] Improve FileSpec undeprecation tests

---
 .../nexus/delta/plugins/storage/files/FilesSpec.scala  | 10 ++++++++++
 1 file changed, 10 insertions(+)

diff --git a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/FilesSpec.scala b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/FilesSpec.scala
index d6db5714d5..e866bd4a1c 100644
--- a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/FilesSpec.scala
+++ b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/FilesSpec.scala
@@ -495,6 +495,7 @@ class FilesSpec(docker: RemoteStorageDocker)
       "succeed" in {
         givenADeprecatedFile { id =>
           files.undeprecate(id, 2).accepted.deprecated shouldEqual false
+          assertActive(id)
         }
       }
 
@@ -505,12 +506,14 @@ class FilesSpec(docker: RemoteStorageDocker)
       "reject if file is not deprecated" in {
         givenAFile { id =>
           files.undeprecate(id, 1).assertRejectedWith[FileIsNotDeprecated]
+          assertRemainsActive(id)
         }
       }
 
       "reject if the revision passed is incorrect" in {
         givenADeprecatedFile { id =>
           files.undeprecate(id, 3).assertRejectedEquals(IncorrectRev(3, 2))
+          assertRemainsDeprecated(id)
         }
       }
 
@@ -634,6 +637,13 @@ class FilesSpec(docker: RemoteStorageDocker)
         assertion(id)
       }
     }
+
+    def assertRemainsDeprecated(id: FileId): Assertion =
+      files.fetch(id).accepted.deprecated shouldEqual true
+    def assertActive(id: FileId): Assertion            =
+      files.fetch(id).accepted.deprecated shouldEqual false
+    def assertRemainsActive(id: FileId): Assertion     =
+      assertActive(id)
   }
 
 }

From e6dc8b8f179bdf2e08bcffe5fe6be6ba47f92fcc Mon Sep 17 00:00:00 2001
From: Oliver <20188437+olivergrabinski@users.noreply.github.com>
Date: Fri, 3 Nov 2023 16:06:31 +0100
Subject: [PATCH 05/22] Make FileRoutesSpec independent w.r.t. permissions

---
 .../files/routes/FilesRoutesSpec.scala        | 183 +++++++++---------
 .../nexus/delta/sdk/utils/RouteFixtures.scala |   1 +
 2 files changed, 95 insertions(+), 89 deletions(-)

diff --git a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala
index 58e25371ee..7710c60afa 100644
--- a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala
+++ b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala
@@ -11,7 +11,6 @@ import cats.effect.IO
 import ch.epfl.bluebrain.nexus.delta.kernel.http.MediaTypeDetectorConfig
 import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model.Digest.ComputedDigest
 import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model.{FileAttributes, FileId, FileRejection}
-import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.routes.FilesRoutesSpec.fileMetadata
 import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.{contexts => fileContexts, permissions, FileFixtures, Files, FilesConfig}
 import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.model.{StorageRejection, StorageStatEntry, StorageType}
 import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.operations.remote.client.RemoteDiskStorageClient
@@ -27,19 +26,18 @@ import ch.epfl.bluebrain.nexus.delta.sdk.acls.model.AclAddress
 import ch.epfl.bluebrain.nexus.delta.sdk.auth.{AuthTokenProvider, Credentials}
 import ch.epfl.bluebrain.nexus.delta.sdk.directives.DeltaSchemeDirectives
 import ch.epfl.bluebrain.nexus.delta.sdk.http.HttpClient
-import ch.epfl.bluebrain.nexus.delta.sdk.identities.{Identities, IdentitiesDummy}
 import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.{Caller, ServiceAccount}
+import ch.epfl.bluebrain.nexus.delta.sdk.identities.{Identities, IdentitiesDummy}
 import ch.epfl.bluebrain.nexus.delta.sdk.implicits._
 import ch.epfl.bluebrain.nexus.delta.sdk.model.{BaseUri, ResourceUris}
 import ch.epfl.bluebrain.nexus.delta.sdk.permissions.Permissions.events
 import ch.epfl.bluebrain.nexus.delta.sdk.permissions.model.Permission
 import ch.epfl.bluebrain.nexus.delta.sdk.projects.FetchContextDummy
 import ch.epfl.bluebrain.nexus.delta.sdk.resolvers.ResolverContextResolution
-import ch.epfl.bluebrain.nexus.delta.sdk.utils.{BaseRouteSpec, RouteFixtures}
+import ch.epfl.bluebrain.nexus.delta.sdk.utils.BaseRouteSpec
 import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.{Anonymous, Authenticated, Group, Subject, User}
 import ch.epfl.bluebrain.nexus.delta.sourcing.model.Tag.UserTag
 import ch.epfl.bluebrain.nexus.delta.sourcing.model.{Label, ProjectRef, ResourceRef}
-import ch.epfl.bluebrain.nexus.testkit._
 import ch.epfl.bluebrain.nexus.testkit.bio.IOFromMap
 import ch.epfl.bluebrain.nexus.testkit.scalatest.ce.CatsIOValues
 import io.circe.Json
@@ -71,11 +69,21 @@ class FilesRoutesSpec
       Vocabulary.contexts.search       -> ContextValue.fromFile("contexts/search.json")
     )
 
-  implicit private val caller: Caller =
-    Caller(alice, Set(alice, Anonymous, Authenticated(realm), Group("group", realm)))
-  private val aliceIdentities         = IdentitiesDummy(caller)
+  private val reader   = alice
+  private val writer   = bob
+  private val s3writer = charlie
+
+  implicit private val callerReader: Caller   =
+    Caller(reader, Set(reader, Anonymous, Authenticated(realm), Group("group", realm)))
+  implicit private val callerWriter: Caller   =
+    Caller(writer, Set(writer, Anonymous, Authenticated(realm), Group("group", realm)))
+  implicit private val callerS3Writer: Caller =
+    Caller(s3writer, Set(s3writer, Anonymous, Authenticated(realm), Group("group", realm)))
+  private val identities                      = IdentitiesDummy(callerReader, callerWriter, callerS3Writer)
 
-  private val asAlice = addCredentials(OAuth2BearerToken("alice"))
+  private val asReader   = addCredentials(OAuth2BearerToken("alice"))
+  private val asWriter   = addCredentials(OAuth2BearerToken("bob"))
+  private val asS3Writer = addCredentials(OAuth2BearerToken("charlie"))
 
   private val fetchContext = FetchContextDummy(Map(project.ref -> project.context))
 
@@ -125,7 +133,7 @@ class FilesRoutesSpec
   private val groupDirectives                              =
     DeltaSchemeDirectives(fetchContext, ioFromMap(uuid -> projectRef.organization), ioFromMap(uuid -> projectRef))
 
-  private lazy val routes                                  = routesWithIdentities(aliceIdentities)
+  private lazy val routes                                  = routesWithIdentities(identities)
   private def routesWithIdentities(identities: Identities) =
     Route.seal(FilesRoutes(stCfg, identities, aclCheck, files, groupDirectives, IndexingAction.noop))
 
@@ -135,23 +143,33 @@ class FilesRoutesSpec
 
   private val varyHeader = RawHeader("Vary", "Accept,Accept-Encoding")
 
-  "File routes" should {
+  override def beforeAll(): Unit = {
+    super.beforeAll()
+    // format: off
+//    aclCheck.append(AclAddress.Root, writer -> Set(storagesPermissions.write), callerWriter.subject -> Set(storagesPermissions.write)).accepted
+//    aclCheck.append(AclAddress.Root, writer -> Set(diskWrite), callerWriter.subject -> Set(diskWrite)).accepted
+//    aclCheck.append(AclAddress.Root, writer -> Set(diskRead), callerWriter.subject -> Set(diskRead)).accepted
+//    aclCheck.append(AclAddress.Root, writer -> Set(permissions.write), callerWriter.subject -> Set(permissions.write)).accepted
+//    aclCheck.append(AclAddress.Root, writer -> Set(permissions.read), callerWriter.subject -> Set(permissions.read)).accepted
+
+    val writePermissions = Set(storagesPermissions.write, diskWrite, permissions.write)
+    val readPermissions = Set(diskRead, s3Read, permissions.read)
+    aclCheck.append(AclAddress.Root, writer -> writePermissions, writer -> readPermissions).accepted
+    aclCheck.append(AclAddress.Root, callerWriter.subject -> writePermissions).accepted
+    aclCheck.append(AclAddress.Root, reader -> readPermissions).accepted
+    aclCheck.append(AclAddress.Root, s3writer -> Set(s3Write), callerS3Writer.subject -> Set(s3Write)).accepted
+    // format: on
+
+    val defaults = json"""{"maxFileSize": 1000, "volume": "$path"}"""
+    val s3Perms  = json"""{"readPermission": "$s3Read", "writePermission": "$s3Write"}"""
+    storages.create(s3Id, projectRef, diskFieldsJson deepMerge defaults deepMerge s3Perms)(callerWriter).accepted
+    storages
+      .create(dId, projectRef, diskFieldsJson deepMerge defaults deepMerge json"""{"capacity":5000}""")(callerWriter)
+      .void
+      .accepted
+  }
 
-    "create storages for files" in {
-      val defaults = json"""{"maxFileSize": 1000, "volume": "$path"}"""
-      val s3Perms  = json"""{"readPermission": "$s3Read", "writePermission": "$s3Write"}"""
-      aclCheck
-        .append(
-          AclAddress.Root,
-          Anonymous      -> Set(storagesPermissions.write),
-          caller.subject -> Set(storagesPermissions.write)
-        )
-        .accepted
-      storages.create(s3Id, projectRef, diskFieldsJson deepMerge defaults deepMerge s3Perms).accepted
-      storages
-        .create(dId, projectRef, diskFieldsJson deepMerge defaults deepMerge json"""{"capacity":5000}""")
-        .accepted
-    }
+  "File routes" should {
 
     "fail to create a file without disk/write permission" in {
       Post("/v1/files/org/proj", entity()) ~> routes ~> check {
@@ -160,8 +178,7 @@ class FilesRoutesSpec
     }
 
     "create a file" in {
-      aclCheck.append(AclAddress.Root, Anonymous -> Set(diskWrite), caller.subject -> Set(diskWrite)).accepted
-      Post("/v1/files/org/proj", entity()) ~> routes ~> check {
+      Post("/v1/files/org/proj", entity()) ~> asWriter ~> routes ~> check {
         status shouldEqual StatusCodes.Created
         val attr = attributes()
         response.asJson shouldEqual fileMetadata(projectRef, generatedId, attr, diskIdRev)
@@ -170,7 +187,7 @@ class FilesRoutesSpec
 
     "create and tag a file" in {
       withUUIDF(uuid2) {
-        Post("/v1/files/org/proj?tag=mytag", entity()) ~> routes ~> check {
+        Post("/v1/files/org/proj?tag=mytag", entity()) ~> asWriter ~> routes ~> check {
           status shouldEqual StatusCodes.Created
           val attr      = attributes(id = uuid2)
           val expected  = fileMetadata(projectRef, generatedId2, attr, diskIdRev)
@@ -183,7 +200,7 @@ class FilesRoutesSpec
 
     "fail to create a file link using a storage that does not allow it" in {
       val payload = json"""{"filename": "my.txt", "path": "my/file.txt", "mediaType": "text/plain"}"""
-      Put("/v1/files/org/proj/file1", payload.toEntity) ~> routes ~> check {
+      Put("/v1/files/org/proj/file1", payload.toEntity) ~> asWriter ~> routes ~> check {
         status shouldEqual StatusCodes.BadRequest
         response.asJson shouldEqual
           jsonContentOf("files/errors/unsupported-operation.json", "id" -> file1, "storageId" -> dId)
@@ -191,30 +208,29 @@ class FilesRoutesSpec
     }
 
     "fail to create a file without s3/write permission" in {
-      Put("/v1/files/org/proj/file1?storage=s3-storage", entity()) ~> routes ~> check {
+      Put("/v1/files/org/proj/file1?storage=s3-storage", entity()) ~> asWriter ~> routes ~> check {
         response.shouldBeForbidden
       }
     }
 
-    "create a file with an authenticated user and provided id" in {
-      aclCheck.append(AclAddress.Root, Anonymous -> Set(s3Write), caller.subject -> Set(s3Write)).accepted
-      Put("/v1/files/org/proj/file1?storage=s3-storage", entity("file2.txt")) ~> asAlice ~> routes ~> check {
+    "create a file on s3 with an authenticated user and provided id" in {
+      Put("/v1/files/org/proj/file1?storage=s3-storage", entity("file2.txt")) ~> asS3Writer ~> routes ~> check {
         status shouldEqual StatusCodes.Created
         val attr = attributes("file2.txt")
         response.asJson shouldEqual
-          fileMetadata(projectRef, file1, attr, s3IdRev, createdBy = alice, updatedBy = alice)
+          fileMetadata(projectRef, file1, attr, s3IdRev, createdBy = s3writer, updatedBy = s3writer)
       }
     }
 
-    "create and tag a file with an authenticated user and provided id" in {
+    "create and tag a file on s3 with an authenticated user and provided id" in {
       withUUIDF(uuid2) {
         Put(
           "/v1/files/org/proj/fileTagged?storage=s3-storage&tag=mytag",
           entity("fileTagged.txt")
-        ) ~> asAlice ~> routes ~> check {
+        ) ~> asS3Writer ~> routes ~> check {
           status shouldEqual StatusCodes.Created
           val attr      = attributes("fileTagged.txt", id = uuid2)
-          val expected  = fileMetadata(projectRef, fileTagged, attr, s3IdRev, createdBy = alice, updatedBy = alice)
+          val expected  = fileMetadata(projectRef, fileTagged, attr, s3IdRev, createdBy = s3writer, updatedBy = s3writer)
           val fileByTag = files.fetch(FileId(generatedId2, tag, projectRef)).accepted
           response.asJson shouldEqual expected
           fileByTag.value.tags.tags should contain(tag)
@@ -223,21 +239,24 @@ class FilesRoutesSpec
     }
 
     "reject the creation of a file which already exists" in {
-      Put("/v1/files/org/proj/file1", entity()) ~> routes ~> check {
+      Put("/v1/files/org/proj/file1", entity()) ~> asWriter ~> routes ~> check {
         status shouldEqual StatusCodes.Conflict
         response.asJson shouldEqual jsonContentOf("/files/errors/already-exists.json", "id" -> file1)
       }
     }
 
     "reject the creation of a file that is too large" in {
-      Put("/v1/files/org/proj/file-too-large", randomEntity(filename = "large-file.txt", 1100)) ~> routes ~> check {
+      Put(
+        "/v1/files/org/proj/file-too-large",
+        randomEntity(filename = "large-file.txt", 1100)
+      ) ~> asWriter ~> routes ~> check {
         status shouldEqual StatusCodes.PayloadTooLarge
         response.asJson shouldEqual jsonContentOf("/files/errors/file-too-large.json")
       }
     }
 
     "reject the creation of a file to a storage that does not exist" in {
-      Put("/v1/files/org/proj/file2?storage=not-exist", entity()) ~> routes ~> check {
+      Put("/v1/files/org/proj/file2?storage=not-exist", entity()) ~> asWriter ~> routes ~> check {
         status shouldEqual StatusCodes.NotFound
         response.asJson shouldEqual
           jsonContentOf("/storages/errors/not-found.json", "id" -> (nxv + "not-exist"), "proj" -> projectRef)
@@ -245,48 +264,41 @@ class FilesRoutesSpec
     }
 
     "fail to update a file without disk/write permission" in {
-      aclCheck.subtract(AclAddress.Root, Anonymous -> Set(diskWrite)).accepted
       Put(s"/v1/files/org/proj/file1?rev=1", s3FieldsJson.toEntity) ~> routes ~> check {
         response.shouldBeForbidden
       }
     }
 
     "update a file" in {
-      aclCheck.append(AclAddress.Root, Anonymous -> Set(diskWrite)).accepted
       val endpoints = List(
         "/v1/files/org/proj/file1",
         s"/v1/files/org/proj/$file1Encoded"
       )
       forAll(endpoints.zipWithIndex) { case (endpoint, idx) =>
         val filename = s"file-idx-$idx.txt"
-        Put(s"$endpoint?rev=${idx + 1}", entity(filename)) ~> routes ~> check {
+        Put(s"$endpoint?rev=${idx + 1}", entity(filename)) ~> asWriter ~> routes ~> check {
           status shouldEqual StatusCodes.OK
           val attr = attributes(filename)
           response.asJson shouldEqual
-            fileMetadata(projectRef, file1, attr, diskIdRev, rev = idx + 2, createdBy = alice)
+            fileMetadata(projectRef, file1, attr, diskIdRev, rev = idx + 2, createdBy = s3writer)
         }
       }
     }
 
     "update and tag a file in one request" in {
-      givenRoutesForUserWithPermissions(Set(diskRead, diskWrite)) { (user, route) =>
-        val token = addCredentials(OAuth2BearerToken(user.subject))
-
-        givenAFile { id =>
-          Put(s"/v1/files/org/proj/$id?rev=1&tag=mytag", entity(s"$id.txt")) ~> token ~> route ~> check {
-            status shouldEqual StatusCodes.OK
-          }
-
-          Get(s"/v1/files/org/proj/$id?tag=mytag") ~> Accept(`*/*`) ~> token ~> route ~> check {
-            status shouldEqual StatusCodes.OK
-          }
+      givenAFile { id =>
+        Put(s"/v1/files/org/proj/$id?rev=1&tag=mytag", entity(s"$id.txt")) ~> asWriter ~> routes ~> check {
+          status shouldEqual StatusCodes.OK
+        }
+        Get(s"/v1/files/org/proj/$id?tag=mytag") ~> Accept(`*/*`) ~> asWriter ~> routes ~> check {
+          status shouldEqual StatusCodes.OK
         }
       }
     }
 
     "fail to update a file link using a storage that does not allow it" in {
       val payload = json"""{"filename": "my.txt", "path": "my/file.txt", "mediaType": "text/plain"}"""
-      Put("/v1/files/org/proj/file1?rev=3", payload.toEntity) ~> routes ~> check {
+      Put("/v1/files/org/proj/file1?rev=3", payload.toEntity) ~> asWriter ~> routes ~> check {
         status shouldEqual StatusCodes.BadRequest
         response.asJson shouldEqual
           jsonContentOf("files/errors/unsupported-operation.json", "id" -> file1, "storageId" -> dId)
@@ -294,7 +306,7 @@ class FilesRoutesSpec
     }
 
     "reject the update of a non-existent file" in {
-      Put("/v1/files/org/proj/myid10?rev=1", entity("other.txt")) ~> routes ~> check {
+      Put("/v1/files/org/proj/myid10?rev=1", entity("other.txt")) ~> asWriter ~> routes ~> check {
         status shouldEqual StatusCodes.NotFound
         response.asJson shouldEqual
           jsonContentOf("/files/errors/not-found.json", "id" -> (nxv + "myid10"), "proj" -> "org/proj")
@@ -302,7 +314,7 @@ class FilesRoutesSpec
     }
 
     "reject the update of a non-existent file storage" in {
-      Put("/v1/files/org/proj/file1?rev=3&storage=not-exist", entity("other.txt")) ~> routes ~> check {
+      Put("/v1/files/org/proj/file1?rev=3&storage=not-exist", entity("other.txt")) ~> asWriter ~> routes ~> check {
         status shouldEqual StatusCodes.NotFound
         response.asJson shouldEqual
           jsonContentOf("/storages/errors/not-found.json", "id" -> (nxv + "not-exist"), "proj" -> projectRef)
@@ -310,7 +322,7 @@ class FilesRoutesSpec
     }
 
     "reject the update of a file at a non-existent revision" in {
-      Put("/v1/files/org/proj/file1?rev=10", entity("other.txt")) ~> routes ~> check {
+      Put("/v1/files/org/proj/file1?rev=10", entity("other.txt")) ~> asWriter ~> routes ~> check {
         status shouldEqual StatusCodes.Conflict
         response.asJson shouldEqual
           jsonContentOf("/files/errors/incorrect-rev.json", "provided" -> 10, "expected" -> 3)
@@ -324,8 +336,7 @@ class FilesRoutesSpec
     }
 
     "deprecate a file" in {
-      aclCheck.append(AclAddress.Root, Anonymous -> Set(permissions.write)).accepted
-      Delete(s"/v1/files/org/proj/$uuid?rev=1") ~> routes ~> check {
+      Delete(s"/v1/files/org/proj/$uuid?rev=1") ~> asWriter ~> routes ~> check {
         status shouldEqual StatusCodes.OK
         val attr = attributes()
         response.asJson shouldEqual fileMetadata(projectRef, generatedId, attr, diskIdRev, rev = 2, deprecated = true)
@@ -333,14 +344,14 @@ class FilesRoutesSpec
     }
 
     "reject the deprecation of a file without rev" in {
-      Delete("/v1/files/org/proj/file1") ~> routes ~> check {
+      Delete("/v1/files/org/proj/file1") ~> asWriter ~> routes ~> check {
         status shouldEqual StatusCodes.BadRequest
         response.asJson shouldEqual jsonContentOf("/errors/missing-query-param.json", "field" -> "rev")
       }
     }
 
     "reject the deprecation of an already deprecated file" in {
-      Delete(s"/v1/files/org/proj/$uuid?rev=2") ~> routes ~> check {
+      Delete(s"/v1/files/org/proj/$uuid?rev=2") ~> asWriter ~> routes ~> check {
         status shouldEqual StatusCodes.BadRequest
         response.asJson shouldEqual jsonContentOf("/files/errors/file-deprecated.json", "id" -> generatedId)
       }
@@ -348,10 +359,10 @@ class FilesRoutesSpec
 
     "tag a file" in {
       val payload = json"""{"tag": "mytag", "rev": 1}"""
-      Post("/v1/files/org/proj/file1/tags?rev=3", payload.toEntity) ~> routes ~> check {
+      Post("/v1/files/org/proj/file1/tags?rev=3", payload.toEntity) ~> asWriter ~> routes ~> check {
         status shouldEqual StatusCodes.Created
         val attr = attributes("file-idx-1.txt")
-        response.asJson shouldEqual fileMetadata(projectRef, file1, attr, diskIdRev, rev = 4, createdBy = alice)
+        response.asJson shouldEqual fileMetadata(projectRef, file1, attr, diskIdRev, rev = 4, createdBy = charlie)
       }
     }
 
@@ -365,9 +376,8 @@ class FilesRoutesSpec
     }
 
     "fail to fetch a file when the accept header does not match file media type" in {
-      aclCheck.append(AclAddress.Root, Anonymous -> Set(diskRead, s3Read)).accepted
       forAll(List("", "?rev=1", "?tags=mytag")) { suffix =>
-        Get(s"/v1/files/org/proj/file1$suffix") ~> Accept(`video/*`) ~> routes ~> check {
+        Get(s"/v1/files/org/proj/file1$suffix") ~> Accept(`video/*`) ~> asReader ~> routes ~> check {
           response.status shouldEqual StatusCodes.NotAcceptable
           response.asJson shouldEqual jsonContentOf("errors/content-type.json", "expected" -> "text/plain")
           response.headers should not contain varyHeader
@@ -380,7 +390,7 @@ class FilesRoutesSpec
         forAll(
           List("/v1/files/org/proj/file1", "/v1/resources/org/proj/_/file1", "/v1/resources/org/proj/file/file1")
         ) { endpoint =>
-          Get(endpoint) ~> accept ~> routes ~> check {
+          Get(endpoint) ~> accept ~> asReader ~> routes ~> check {
             status shouldEqual StatusCodes.OK
             contentType.value shouldEqual `text/plain(UTF-8)`.value
             val filename64 = "ZmlsZS1pZHgtMS50eHQ=" // file-idx-1.txt
@@ -407,7 +417,7 @@ class FilesRoutesSpec
       )
       forAll(endpoints) { endpoint =>
         forAll(List("rev=1", "tag=mytag")) { param =>
-          Get(s"$endpoint?$param") ~> Accept(`*/*`) ~> routes ~> check {
+          Get(s"$endpoint?$param") ~> Accept(`*/*`) ~> asReader ~> routes ~> check {
             status shouldEqual StatusCodes.OK
             contentType.value shouldEqual `text/plain(UTF-8)`.value
             val filename64 = "ZmlsZTIudHh0" // file2.txt
@@ -434,11 +444,10 @@ class FilesRoutesSpec
     }
 
     "fetch a file metadata" in {
-      aclCheck.append(AclAddress.Root, Anonymous -> Set(permissions.read)).accepted
-      Get("/v1/files/org/proj/file1") ~> Accept(`application/ld+json`) ~> routes ~> check {
+      Get("/v1/files/org/proj/file1") ~> Accept(`application/ld+json`) ~> asReader ~> routes ~> check {
         status shouldEqual StatusCodes.OK
         val attr = attributes("file-idx-1.txt")
-        response.asJson shouldEqual fileMetadata(projectRef, file1, attr, diskIdRev, rev = 4, createdBy = alice)
+        response.asJson shouldEqual fileMetadata(projectRef, file1, attr, diskIdRev, rev = 4, createdBy = s3writer)
         response.headers should contain(varyHeader)
       }
     }
@@ -456,10 +465,10 @@ class FilesRoutesSpec
       )
       forAll(endpoints) { endpoint =>
         forAll(List("rev=1", "tag=mytag")) { param =>
-          Get(s"$endpoint?$param") ~> Accept(`application/ld+json`) ~> routes ~> check {
+          Get(s"$endpoint?$param") ~> Accept(`application/ld+json`) ~> asReader ~> routes ~> check {
             status shouldEqual StatusCodes.OK
             response.asJson shouldEqual
-              fileMetadata(projectRef, file1, attr, s3IdRev, createdBy = alice, updatedBy = alice)
+              fileMetadata(projectRef, file1, attr, s3IdRev, createdBy = s3writer, updatedBy = s3writer)
             response.headers should contain(varyHeader)
           }
         }
@@ -467,47 +476,47 @@ class FilesRoutesSpec
     }
 
     "fetch the file tags" in {
-      Get("/v1/resources/org/proj/_/file1/tags?rev=1") ~> routes ~> check {
+      Get("/v1/resources/org/proj/_/file1/tags?rev=1") ~> asReader ~> routes ~> check {
         status shouldEqual StatusCodes.OK
         response.asJson shouldEqual json"""{"tags": []}""".addContext(contexts.tags)
       }
-      Get("/v1/files/org/proj/file1/tags") ~> routes ~> check {
+      Get("/v1/files/org/proj/file1/tags") ~> asReader ~> routes ~> check {
         status shouldEqual StatusCodes.OK
         response.asJson shouldEqual json"""{"tags": [{"rev": 1, "tag": "mytag"}]}""".addContext(contexts.tags)
       }
     }
 
     "return not found if tag not found" in {
-      Get("/v1/files/org/proj/file1?tag=myother") ~> Accept(`application/ld+json`) ~> routes ~> check {
+      Get("/v1/files/org/proj/file1?tag=myother") ~> Accept(`application/ld+json`) ~> asReader ~> routes ~> check {
         status shouldEqual StatusCodes.NotFound
         response.asJson shouldEqual jsonContentOf("/errors/tag-not-found.json", "tag" -> "myother")
       }
     }
 
     "reject if provided rev and tag simultaneously" in {
-      Get("/v1/files/org/proj/file1?tag=mytag&rev=1") ~> Accept(`application/ld+json`) ~> routes ~> check {
+      Get("/v1/files/org/proj/file1?tag=mytag&rev=1") ~> Accept(`application/ld+json`) ~> asReader ~> routes ~> check {
         status shouldEqual StatusCodes.BadRequest
         response.asJson shouldEqual jsonContentOf("/errors/tag-and-rev-error.json")
       }
     }
 
     "delete a tag on file" in {
-      Delete("/v1/files/org/proj/file1/tags/mytag?rev=4") ~> routes ~> check {
+      Delete("/v1/files/org/proj/file1/tags/mytag?rev=4") ~> asWriter ~> routes ~> check {
         val attr = attributes("file-idx-1.txt")
         status shouldEqual StatusCodes.OK
-        response.asJson shouldEqual fileMetadata(projectRef, file1, attr, diskIdRev, rev = 5, createdBy = alice)
+        response.asJson shouldEqual fileMetadata(projectRef, file1, attr, diskIdRev, rev = 5, createdBy = s3writer)
       }
     }
 
     "not return the deleted tag" in {
-      Get("/v1/files/org/proj/file1/tags") ~> routes ~> check {
+      Get("/v1/files/org/proj/file1/tags") ~> asReader ~> routes ~> check {
         status shouldEqual StatusCodes.OK
         response.asJson shouldEqual json"""{"tags": []}""".addContext(contexts.tags)
       }
     }
 
     "fail to fetch file by the deleted tag" in {
-      Get("/v1/files/org/proj/file1?tag=mytag") ~> Accept(`application/ld+json`) ~> routes ~> check {
+      Get("/v1/files/org/proj/file1?tag=mytag") ~> Accept(`application/ld+json`) ~> asReader ~> routes ~> check {
         status shouldEqual StatusCodes.NotFound
         response.asJson shouldEqual jsonContentOf("/errors/tag-not-found.json", "tag" -> "mytag")
       }
@@ -523,7 +532,7 @@ class FilesRoutesSpec
 
   def givenAFile(test: String => Assertion): Assertion = {
     val id = genString()
-    Put(s"/v1/files/org/proj/$id", entity(s"${genString()}.txt")) ~> routes ~> check {
+    Put(s"/v1/files/org/proj/$id", entity(s"${genString()}.txt")) ~> asWriter ~> routes ~> check {
       status shouldEqual StatusCodes.Created
     }
     test(id)
@@ -544,9 +553,6 @@ class FilesRoutesSpec
     aclCheck.append(AclAddress.Root, c.subject -> perms).accepted
     test(user, c)
   }
-}
-
-object FilesRoutesSpec extends TestHelpers with RouteFixtures {
 
   def fileMetadata(
       project: ProjectRef,
@@ -556,8 +562,8 @@ object FilesRoutesSpec extends TestHelpers with RouteFixtures {
       storageType: StorageType = StorageType.DiskStorage,
       rev: Int = 1,
       deprecated: Boolean = false,
-      createdBy: Subject = Anonymous,
-      updatedBy: Subject = Anonymous
+      createdBy: Subject = callerWriter.subject,
+      updatedBy: Subject = callerWriter.subject
   )(implicit baseUri: BaseUri): Json =
     jsonContentOf(
       "files/file-route-metadata-response.json",
@@ -580,5 +586,4 @@ object FilesRoutesSpec extends TestHelpers with RouteFixtures {
       "type"        -> storageType,
       "self"        -> ResourceUris("files", project, id).accessUri
     )
-
 }
diff --git a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/utils/RouteFixtures.scala b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/utils/RouteFixtures.scala
index e4a2d40c75..81cd9c8d7e 100644
--- a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/utils/RouteFixtures.scala
+++ b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/utils/RouteFixtures.scala
@@ -65,4 +65,5 @@ trait RouteFixtures {
   val realm: Label = Label.unsafe("wonderland")
   val alice: User  = User("alice", realm)
   val bob: User    = User("bob", realm)
+  val charlie: User = User("charlie", realm)
 }

From 216f93172b6b704359a654ba46b119a4e12eaed1 Mon Sep 17 00:00:00 2001
From: Oliver <20188437+olivergrabinski@users.noreply.github.com>
Date: Fri, 3 Nov 2023 16:10:49 +0100
Subject: [PATCH 06/22] Use non-confusing names

---
 .../files/routes/FilesRoutesSpec.scala        | 38 ++++++++-----------
 .../nexus/delta/sdk/utils/RouteFixtures.scala |  1 -
 2 files changed, 16 insertions(+), 23 deletions(-)

diff --git a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala
index 7710c60afa..918ff3b464 100644
--- a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala
+++ b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala
@@ -69,9 +69,9 @@ class FilesRoutesSpec
       Vocabulary.contexts.search       -> ContextValue.fromFile("contexts/search.json")
     )
 
-  private val reader   = alice
-  private val writer   = bob
-  private val s3writer = charlie
+  private val reader   = User("reader", realm)
+  private val writer   = User("writer", realm)
+  private val s3writer = User("s3writer", realm)
 
   implicit private val callerReader: Caller   =
     Caller(reader, Set(reader, Anonymous, Authenticated(realm), Group("group", realm)))
@@ -81,17 +81,18 @@ class FilesRoutesSpec
     Caller(s3writer, Set(s3writer, Anonymous, Authenticated(realm), Group("group", realm)))
   private val identities                      = IdentitiesDummy(callerReader, callerWriter, callerS3Writer)
 
-  private val asReader   = addCredentials(OAuth2BearerToken("alice"))
-  private val asWriter   = addCredentials(OAuth2BearerToken("bob"))
-  private val asS3Writer = addCredentials(OAuth2BearerToken("charlie"))
+  private val asReader   = addCredentials(OAuth2BearerToken("reader"))
+  private val asWriter   = addCredentials(OAuth2BearerToken("writer"))
+  private val asS3Writer = addCredentials(OAuth2BearerToken("s3writer"))
 
   private val fetchContext = FetchContextDummy(Map(project.ref -> project.context))
 
-  private val s3Read        = Permission.unsafe("s3/read")
-  private val s3Write       = Permission.unsafe("s3/write")
-  private val diskRead      = Permission.unsafe("disk/read")
-  private val diskWrite     = Permission.unsafe("disk/write")
-  override val allowedPerms =
+  private val s3Read    = Permission.unsafe("s3/read")
+  private val s3Write   = Permission.unsafe("s3/write")
+  private val diskRead  = Permission.unsafe("disk/read")
+  private val diskWrite = Permission.unsafe("disk/write")
+
+  override val allowedPerms: Seq[Permission] =
     Seq(
       permissions.read,
       permissions.write,
@@ -145,23 +146,16 @@ class FilesRoutesSpec
 
   override def beforeAll(): Unit = {
     super.beforeAll()
-    // format: off
-//    aclCheck.append(AclAddress.Root, writer -> Set(storagesPermissions.write), callerWriter.subject -> Set(storagesPermissions.write)).accepted
-//    aclCheck.append(AclAddress.Root, writer -> Set(diskWrite), callerWriter.subject -> Set(diskWrite)).accepted
-//    aclCheck.append(AclAddress.Root, writer -> Set(diskRead), callerWriter.subject -> Set(diskRead)).accepted
-//    aclCheck.append(AclAddress.Root, writer -> Set(permissions.write), callerWriter.subject -> Set(permissions.write)).accepted
-//    aclCheck.append(AclAddress.Root, writer -> Set(permissions.read), callerWriter.subject -> Set(permissions.read)).accepted
 
     val writePermissions = Set(storagesPermissions.write, diskWrite, permissions.write)
-    val readPermissions = Set(diskRead, s3Read, permissions.read)
+    val readPermissions  = Set(diskRead, s3Read, permissions.read)
     aclCheck.append(AclAddress.Root, writer -> writePermissions, writer -> readPermissions).accepted
     aclCheck.append(AclAddress.Root, callerWriter.subject -> writePermissions).accepted
     aclCheck.append(AclAddress.Root, reader -> readPermissions).accepted
     aclCheck.append(AclAddress.Root, s3writer -> Set(s3Write), callerS3Writer.subject -> Set(s3Write)).accepted
-    // format: on
 
-    val defaults = json"""{"maxFileSize": 1000, "volume": "$path"}"""
-    val s3Perms  = json"""{"readPermission": "$s3Read", "writePermission": "$s3Write"}"""
+    val defaults         = json"""{"maxFileSize": 1000, "volume": "$path"}"""
+    val s3Perms          = json"""{"readPermission": "$s3Read", "writePermission": "$s3Write"}"""
     storages.create(s3Id, projectRef, diskFieldsJson deepMerge defaults deepMerge s3Perms)(callerWriter).accepted
     storages
       .create(dId, projectRef, diskFieldsJson deepMerge defaults deepMerge json"""{"capacity":5000}""")(callerWriter)
@@ -362,7 +356,7 @@ class FilesRoutesSpec
       Post("/v1/files/org/proj/file1/tags?rev=3", payload.toEntity) ~> asWriter ~> routes ~> check {
         status shouldEqual StatusCodes.Created
         val attr = attributes("file-idx-1.txt")
-        response.asJson shouldEqual fileMetadata(projectRef, file1, attr, diskIdRev, rev = 4, createdBy = charlie)
+        response.asJson shouldEqual fileMetadata(projectRef, file1, attr, diskIdRev, rev = 4, createdBy = s3writer)
       }
     }
 
diff --git a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/utils/RouteFixtures.scala b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/utils/RouteFixtures.scala
index 81cd9c8d7e..e4a2d40c75 100644
--- a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/utils/RouteFixtures.scala
+++ b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/utils/RouteFixtures.scala
@@ -65,5 +65,4 @@ trait RouteFixtures {
   val realm: Label = Label.unsafe("wonderland")
   val alice: User  = User("alice", realm)
   val bob: User    = User("bob", realm)
-  val charlie: User = User("charlie", realm)
 }

From c89bf3dd13bc2d8996488a8b7cf4615300476975 Mon Sep 17 00:00:00 2001
From: Oliver <20188437+olivergrabinski@users.noreply.github.com>
Date: Fri, 3 Nov 2023 16:37:17 +0100
Subject: [PATCH 07/22] Add undeprecate endpoint and test it

---
 .../storage/files/routes/FilesRoutes.scala    | 13 +++-
 .../errors/file-is-not-deprecated.json        |  5 ++
 .../files/routes/FilesRoutesSpec.scala        | 62 ++++++++++++++-----
 .../testkit/scalatest/ce/CatsIOValues.scala   |  2 +-
 4 files changed, 65 insertions(+), 17 deletions(-)
 create mode 100644 delta/plugins/storage/src/test/resources/errors/file-is-not-deprecated.json

diff --git a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutes.scala b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutes.scala
index 519bae3a4e..3b74470b49 100644
--- a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutes.scala
+++ b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutes.scala
@@ -126,7 +126,6 @@ final class FilesRoutes(
                                 },
                                 // Create a file with id segment
                                 extractRequestEntity { entity =>
-                                  println(s"DTBDTB or only here?")
                                   emit(
                                     Created,
                                     files.create(fileId, storage, entity, tag).index(mode).attemptNarrow[FileRejection]
@@ -168,6 +167,7 @@ final class FilesRoutes(
                             )
                           }
                         },
+
                         // Fetch a file
                         (get & idSegmentRef(id)) { id =>
                           emitOrFusionRedirect(ref, id, fetch(FileId(id, ref)))
@@ -213,6 +213,17 @@ final class FilesRoutes(
                         }
                       )
                     }
+                  },
+                  (pathPrefix("undeprecate") & put & parameter("rev".as[Int])) { rev =>
+                    authorizeFor(ref, Write).apply {
+                      emit(
+                        files
+                          .undeprecate(fileId, rev)
+                          .index(mode)
+                          .attemptNarrow[FileRejection]
+                          .rejectOn[FileNotFound]
+                      )
+                    }
                   }
                 )
               }
diff --git a/delta/plugins/storage/src/test/resources/errors/file-is-not-deprecated.json b/delta/plugins/storage/src/test/resources/errors/file-is-not-deprecated.json
new file mode 100644
index 0000000000..2a749bfbc7
--- /dev/null
+++ b/delta/plugins/storage/src/test/resources/errors/file-is-not-deprecated.json
@@ -0,0 +1,5 @@
+{
+  "@context" : "https://bluebrain.github.io/nexus/contexts/error.json",
+  "@type" : "FileIsNotDeprecated",
+  "reason" : "File '{{id}}' is not deprecated."
+}
\ No newline at end of file
diff --git a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala
index 918ff3b464..6060b5ce44 100644
--- a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala
+++ b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala
@@ -351,6 +351,46 @@ class FilesRoutesSpec
       }
     }
 
+    "fail to undeprecate a file without files/write permission" in {
+      givenADeprecatedFile { id =>
+        Put(s"/v1/files/org/proj/$id/undeprecate?rev=2") ~> asReader ~> routes ~> check {
+          response.shouldBeForbidden
+        }
+      }
+    }
+
+    "undeprecate a file" in {
+      givenADeprecatedFile { id =>
+        Put(s"/v1/files/org/proj/$id/undeprecate?rev=2") ~> asWriter ~> routes ~> check {
+          status shouldEqual StatusCodes.OK
+          response.asJson shouldEqual
+            fileMetadata(projectRef, nxv + id, attributes(id), diskIdRev, rev = 3, deprecated = false)
+
+          Get(s"/v1/files/org/proj/$id") ~> Accept(`*/*`) ~> asReader ~> routes ~> check {
+            status shouldEqual StatusCodes.OK
+          }
+        }
+      }
+    }
+
+    "reject the undeprecation of a file without rev" in {
+      givenADeprecatedFile { id =>
+        Put(s"/v1/files/org/proj/$id/undeprecate") ~> asWriter ~> routes ~> check {
+          status shouldEqual StatusCodes.BadRequest
+          response.asJson shouldEqual jsonContentOf("/errors/missing-query-param.json", "field" -> "rev")
+        }
+      }
+    }
+
+    "reject the undeprecation of a file that is not deprecated" in {
+      givenAFile { id =>
+        Put(s"/v1/files/org/proj/$id/undeprecate?rev=1") ~> asWriter ~> routes ~> check {
+          status shouldEqual StatusCodes.BadRequest
+          response.asJson shouldEqual jsonContentOf("/errors/file-is-not-deprecated.json", "id" -> (nxv + id))
+        }
+      }
+    }
+
     "tag a file" in {
       val payload = json"""{"tag": "mytag", "rev": 1}"""
       Post("/v1/files/org/proj/file1/tags?rev=3", payload.toEntity) ~> asWriter ~> routes ~> check {
@@ -526,28 +566,20 @@ class FilesRoutesSpec
 
   def givenAFile(test: String => Assertion): Assertion = {
     val id = genString()
-    Put(s"/v1/files/org/proj/$id", entity(s"${genString()}.txt")) ~> asWriter ~> routes ~> check {
+    Put(s"/v1/files/org/proj/$id", entity(s"$id")) ~> asWriter ~> routes ~> check {
       status shouldEqual StatusCodes.Created
     }
     test(id)
   }
 
-  def givenRoutesForUserWithPermissions(
-      perms: Set[Permission]
-  )(test: (User, Route) => Assertion): Assertion =
-    givenAUserWithPermissions(perms) { (user, caller) =>
-      val authedRoutes = routesWithIdentities(IdentitiesDummy(caller))
-      test(user, authedRoutes)
+  def givenADeprecatedFile(test: String => Assertion): Assertion =
+    givenAFile { id =>
+      Delete(s"/v1/files/org/proj/$id?rev=1") ~> asWriter ~> routes ~> check {
+        status shouldEqual StatusCodes.OK
+      }
+      test(id)
     }
 
-  def givenAUserWithPermissions(perms: Set[Permission])(test: (User, Caller) => Assertion): Assertion = {
-    val userId     = genString()
-    val user: User = User(userId, realm)
-    val c: Caller  = Caller(user, Set(user, Anonymous, Authenticated(realm), Group("group", realm)))
-    aclCheck.append(AclAddress.Root, c.subject -> perms).accepted
-    test(user, c)
-  }
-
   def fileMetadata(
       project: ProjectRef,
       id: Iri,
diff --git a/delta/testkit/src/main/scala/ch/epfl/bluebrain/nexus/testkit/scalatest/ce/CatsIOValues.scala b/delta/testkit/src/main/scala/ch/epfl/bluebrain/nexus/testkit/scalatest/ce/CatsIOValues.scala
index 86992ce627..155742eb7f 100644
--- a/delta/testkit/src/main/scala/ch/epfl/bluebrain/nexus/testkit/scalatest/ce/CatsIOValues.scala
+++ b/delta/testkit/src/main/scala/ch/epfl/bluebrain/nexus/testkit/scalatest/ce/CatsIOValues.scala
@@ -14,7 +14,7 @@ trait CatsIOValues {
   implicit final class CatsIOValuesOps[A](private val io: IO[A]) {
     def accepted(implicit pos: source.Position): A = {
       io.attempt.unsafeRunTimed(45.seconds).getOrElse(fail("IO timed out during .accepted call")) match {
-        case Left(e) => fail(s"IO failed when it was expected to succeed. Failure details: $e")
+        case Left(e)      => fail(s"IO failed when it was expected to succeed. Failure details: $e")
         case Right(value) => value
       }
     }

From 2d56da31c10586c0eb09e975e0fdcae3914c4a59 Mon Sep 17 00:00:00 2001
From: Oliver <20188437+olivergrabinski@users.noreply.github.com>
Date: Fri, 3 Nov 2023 17:46:57 +0100
Subject: [PATCH 08/22] Make FilesRoutesSpec tests independent

---
 .../plugins/storage/files/FileFixtures.scala  |   8 +-
 .../files/routes/FilesRoutesSpec.scala        | 377 +++++++++++-------
 2 files changed, 231 insertions(+), 154 deletions(-)

diff --git a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/FileFixtures.scala b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/FileFixtures.scala
index b0bd6117b7..9efb9fafc2 100644
--- a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/FileFixtures.scala
+++ b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/FileFixtures.scala
@@ -19,7 +19,7 @@ import monix.bio.Task
 import org.scalatest.Suite
 
 import java.nio.file.{Files => JavaFiles}
-import java.util.UUID
+import java.util.{Base64, UUID}
 
 trait FileFixtures extends EitherValues with BIOValues {
 
@@ -41,6 +41,7 @@ trait FileFixtures extends EitherValues with BIOValues {
   val fileTagged               = nxv + "fileTagged"
   val fileTagged2              = nxv + "fileTagged2"
   val file1Encoded             = UrlUtils.encode(file1.toString)
+  val encodeId                 = (id: String) => UrlUtils.encode((nxv + id).toString)
   val generatedId              = project.base.iri / uuid.toString
   val generatedId2             = project.base.iri / uuid2.toString
 
@@ -82,4 +83,9 @@ trait FileFixtures extends EitherValues with BIOValues {
         Multipart.FormData.BodyPart("file", HttpEntity(`text/plain(UTF-8)`, "0" * size), Map("filename" -> filename))
       )
       .toEntity()
+
+  def base64encode(input: String) = {
+    val encodedBytes = Base64.getEncoder.encode(input.getBytes("UTF-8"))
+    new String(encodedBytes, "UTF-8")
+  }
 }
diff --git a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala
index 6060b5ce44..bd9e4240b9 100644
--- a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala
+++ b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala
@@ -140,7 +140,7 @@ class FilesRoutesSpec
 
   private val diskIdRev = ResourceRef.Revision(dId, 1)
   private val s3IdRev   = ResourceRef.Revision(s3Id, 2)
-  private val tag       = UserTag.unsafe("mytag")
+  private val tag       = "mytag"
 
   private val varyHeader = RawHeader("Vary", "Accept,Accept-Encoding")
 
@@ -185,9 +185,10 @@ class FilesRoutesSpec
           status shouldEqual StatusCodes.Created
           val attr      = attributes(id = uuid2)
           val expected  = fileMetadata(projectRef, generatedId2, attr, diskIdRev)
-          val fileByTag = files.fetch(FileId(generatedId2, tag, projectRef)).accepted
+          val userTag   = UserTag.unsafe(tag)
+          val fileByTag = files.fetch(FileId(generatedId2, userTag, projectRef)).accepted
           response.asJson shouldEqual expected
-          fileByTag.value.tags.tags should contain(tag)
+          fileByTag.value.tags.tags should contain(userTag)
         }
       }
     }
@@ -208,11 +209,12 @@ class FilesRoutesSpec
     }
 
     "create a file on s3 with an authenticated user and provided id" in {
-      Put("/v1/files/org/proj/file1?storage=s3-storage", entity("file2.txt")) ~> asS3Writer ~> routes ~> check {
+      val id = genString()
+      Put(s"/v1/files/org/proj/$id?storage=s3-storage", entity(id)) ~> asS3Writer ~> routes ~> check {
         status shouldEqual StatusCodes.Created
-        val attr = attributes("file2.txt")
+        val attr = attributes(id)
         response.asJson shouldEqual
-          fileMetadata(projectRef, file1, attr, s3IdRev, createdBy = s3writer, updatedBy = s3writer)
+          fileMetadata(projectRef, nxv + id, attr, s3IdRev, createdBy = s3writer, updatedBy = s3writer)
       }
     }
 
@@ -225,17 +227,20 @@ class FilesRoutesSpec
           status shouldEqual StatusCodes.Created
           val attr      = attributes("fileTagged.txt", id = uuid2)
           val expected  = fileMetadata(projectRef, fileTagged, attr, s3IdRev, createdBy = s3writer, updatedBy = s3writer)
-          val fileByTag = files.fetch(FileId(generatedId2, tag, projectRef)).accepted
+          val userTag = UserTag.unsafe(tag)
+          val fileByTag = files.fetch(FileId(generatedId2, userTag, projectRef)).accepted
           response.asJson shouldEqual expected
-          fileByTag.value.tags.tags should contain(tag)
+          fileByTag.value.tags.tags should contain(userTag)
         }
       }
     }
 
     "reject the creation of a file which already exists" in {
-      Put("/v1/files/org/proj/file1", entity()) ~> asWriter ~> routes ~> check {
-        status shouldEqual StatusCodes.Conflict
-        response.asJson shouldEqual jsonContentOf("/files/errors/already-exists.json", "id" -> file1)
+      givenAFile { id =>
+        Put(s"/v1/files/org/proj/$id", entity()) ~> asWriter ~> routes ~> check {
+          status shouldEqual StatusCodes.Conflict
+          response.asJson shouldEqual jsonContentOf("/files/errors/already-exists.json", "id" -> (nxv + id))
+        }
       }
     }
 
@@ -258,23 +263,27 @@ class FilesRoutesSpec
     }
 
     "fail to update a file without disk/write permission" in {
-      Put(s"/v1/files/org/proj/file1?rev=1", s3FieldsJson.toEntity) ~> routes ~> check {
-        response.shouldBeForbidden
+      givenAFile { id =>
+        Put(s"/v1/files/org/proj/$id?rev=1", s3FieldsJson.toEntity) ~> routes ~> check {
+          response.shouldBeForbidden
+        }
       }
     }
 
     "update a file" in {
-      val endpoints = List(
-        "/v1/files/org/proj/file1",
-        s"/v1/files/org/proj/$file1Encoded"
-      )
-      forAll(endpoints.zipWithIndex) { case (endpoint, idx) =>
-        val filename = s"file-idx-$idx.txt"
-        Put(s"$endpoint?rev=${idx + 1}", entity(filename)) ~> asWriter ~> routes ~> check {
-          status shouldEqual StatusCodes.OK
-          val attr = attributes(filename)
-          response.asJson shouldEqual
-            fileMetadata(projectRef, file1, attr, diskIdRev, rev = idx + 2, createdBy = s3writer)
+      givenAFile { id =>
+        val endpoints = List(
+          s"/v1/files/org/proj/$id",
+          s"/v1/files/org/proj/${encodeId(id)}"
+        )
+        forAll(endpoints.zipWithIndex) { case (endpoint, idx) =>
+          val filename = s"file-idx-$idx.txt"
+          Put(s"$endpoint?rev=${idx + 1}", entity(filename)) ~> asWriter ~> routes ~> check {
+            status shouldEqual StatusCodes.OK
+            val attr = attributes(filename)
+            response.asJson shouldEqual
+              fileMetadata(projectRef, nxv + id, attr, diskIdRev, rev = idx + 2)
+          }
         }
       }
     }
@@ -291,63 +300,78 @@ class FilesRoutesSpec
     }
 
     "fail to update a file link using a storage that does not allow it" in {
-      val payload = json"""{"filename": "my.txt", "path": "my/file.txt", "mediaType": "text/plain"}"""
-      Put("/v1/files/org/proj/file1?rev=3", payload.toEntity) ~> asWriter ~> routes ~> check {
-        status shouldEqual StatusCodes.BadRequest
-        response.asJson shouldEqual
-          jsonContentOf("files/errors/unsupported-operation.json", "id" -> file1, "storageId" -> dId)
+      givenAFile { id =>
+        val payload = json"""{"filename": "my.txt", "path": "my/file.txt", "mediaType": "text/plain"}"""
+        Put(s"/v1/files/org/proj/$id?rev=1", payload.toEntity) ~> asWriter ~> routes ~> check {
+          status shouldEqual StatusCodes.BadRequest
+          response.asJson shouldEqual
+            jsonContentOf("files/errors/unsupported-operation.json", "id" -> (nxv + id), "storageId" -> dId)
+        }
       }
     }
 
     "reject the update of a non-existent file" in {
-      Put("/v1/files/org/proj/myid10?rev=1", entity("other.txt")) ~> asWriter ~> routes ~> check {
+      val nonExistentFile = genString()
+      Put(s"/v1/files/org/proj/$nonExistentFile?rev=1", entity("other.txt")) ~> asWriter ~> routes ~> check {
         status shouldEqual StatusCodes.NotFound
         response.asJson shouldEqual
-          jsonContentOf("/files/errors/not-found.json", "id" -> (nxv + "myid10"), "proj" -> "org/proj")
+          jsonContentOf("/files/errors/not-found.json", "id" -> (nxv + nonExistentFile), "proj" -> "org/proj")
       }
     }
 
     "reject the update of a non-existent file storage" in {
-      Put("/v1/files/org/proj/file1?rev=3&storage=not-exist", entity("other.txt")) ~> asWriter ~> routes ~> check {
-        status shouldEqual StatusCodes.NotFound
-        response.asJson shouldEqual
-          jsonContentOf("/storages/errors/not-found.json", "id" -> (nxv + "not-exist"), "proj" -> projectRef)
+      givenAFile { id =>
+        Put(s"/v1/files/org/proj/$id?rev=1&storage=not-exist", entity("other.txt")) ~> asWriter ~> routes ~> check {
+          status shouldEqual StatusCodes.NotFound
+          response.asJson shouldEqual
+            jsonContentOf("/storages/errors/not-found.json", "id" -> (nxv + "not-exist"), "proj" -> projectRef)
+        }
       }
     }
 
     "reject the update of a file at a non-existent revision" in {
-      Put("/v1/files/org/proj/file1?rev=10", entity("other.txt")) ~> asWriter ~> routes ~> check {
-        status shouldEqual StatusCodes.Conflict
-        response.asJson shouldEqual
-          jsonContentOf("/files/errors/incorrect-rev.json", "provided" -> 10, "expected" -> 3)
+      givenAFile { id =>
+        Put(s"/v1/files/org/proj/$id?rev=10", entity("other.txt")) ~> asWriter ~> routes ~> check {
+          status shouldEqual StatusCodes.Conflict
+          response.asJson shouldEqual
+            jsonContentOf("/files/errors/incorrect-rev.json", "provided" -> 10, "expected" -> 1)
+        }
       }
     }
 
     "fail to deprecate a file without files/write permission" in {
-      Delete(s"/v1/files/org/proj/$uuid?rev=1") ~> routes ~> check {
-        response.shouldBeForbidden
+      givenAFile { id =>
+        Delete(s"/v1/files/org/proj/$id?rev=1") ~> routes ~> check {
+          response.shouldBeForbidden
+        }
       }
     }
 
     "deprecate a file" in {
-      Delete(s"/v1/files/org/proj/$uuid?rev=1") ~> asWriter ~> routes ~> check {
-        status shouldEqual StatusCodes.OK
-        val attr = attributes()
-        response.asJson shouldEqual fileMetadata(projectRef, generatedId, attr, diskIdRev, rev = 2, deprecated = true)
+      givenAFile { id =>
+        Delete(s"/v1/files/org/proj/$id?rev=1") ~> asWriter ~> routes ~> check {
+          status shouldEqual StatusCodes.OK
+          val attr = attributes(id)
+          response.asJson shouldEqual fileMetadata(projectRef, nxv + id, attr, diskIdRev, rev = 2, deprecated = true)
+        }
       }
     }
 
     "reject the deprecation of a file without rev" in {
-      Delete("/v1/files/org/proj/file1") ~> asWriter ~> routes ~> check {
-        status shouldEqual StatusCodes.BadRequest
-        response.asJson shouldEqual jsonContentOf("/errors/missing-query-param.json", "field" -> "rev")
+      givenAFile { id =>
+        Delete(s"/v1/files/org/proj/$id") ~> asWriter ~> routes ~> check {
+          status shouldEqual StatusCodes.BadRequest
+          response.asJson shouldEqual jsonContentOf("/errors/missing-query-param.json", "field" -> "rev")
+        }
       }
     }
 
     "reject the deprecation of an already deprecated file" in {
-      Delete(s"/v1/files/org/proj/$uuid?rev=2") ~> asWriter ~> routes ~> check {
-        status shouldEqual StatusCodes.BadRequest
-        response.asJson shouldEqual jsonContentOf("/files/errors/file-deprecated.json", "id" -> generatedId)
+      givenADeprecatedFile { id =>
+        Delete(s"/v1/files/org/proj/$id?rev=2") ~> asWriter ~> routes ~> check {
+          status shouldEqual StatusCodes.BadRequest
+          response.asJson shouldEqual jsonContentOf("/files/errors/file-deprecated.json", "id" -> (nxv + id))
+        }
       }
     }
 
@@ -392,174 +416,213 @@ class FilesRoutesSpec
     }
 
     "tag a file" in {
-      val payload = json"""{"tag": "mytag", "rev": 1}"""
-      Post("/v1/files/org/proj/file1/tags?rev=3", payload.toEntity) ~> asWriter ~> routes ~> check {
-        status shouldEqual StatusCodes.Created
-        val attr = attributes("file-idx-1.txt")
-        response.asJson shouldEqual fileMetadata(projectRef, file1, attr, diskIdRev, rev = 4, createdBy = s3writer)
+      givenAFile { id =>
+        val payload = json"""{"tag": "mytag", "rev": 1}"""
+        Post(s"/v1/files/org/proj/$id/tags?rev=1", payload.toEntity) ~> asWriter ~> routes ~> check {
+          status shouldEqual StatusCodes.Created
+          val attr = attributes(id)
+          response.asJson shouldEqual fileMetadata(projectRef, nxv + id, attr, diskIdRev, rev = 2)
+        }
       }
     }
 
     "fail to fetch a file without s3/read permission" in {
-      forAll(List("", "?rev=1", "?tags=mytag")) { suffix =>
-        Get(s"/v1/files/org/proj/file1$suffix") ~> Accept(`*/*`) ~> routes ~> check {
-          response.shouldBeForbidden
-          response.headers should not contain varyHeader
+      givenATaggedFile(tag) { id =>
+        forAll(List("", "?rev=1", s"?tags=$tag")) { suffix =>
+          Get(s"/v1/files/org/proj/$id$suffix") ~> Accept(`*/*`) ~> routes ~> check {
+            response.shouldBeForbidden
+            response.headers should not contain varyHeader
+          }
         }
       }
     }
 
     "fail to fetch a file when the accept header does not match file media type" in {
-      forAll(List("", "?rev=1", "?tags=mytag")) { suffix =>
-        Get(s"/v1/files/org/proj/file1$suffix") ~> Accept(`video/*`) ~> asReader ~> routes ~> check {
-          response.status shouldEqual StatusCodes.NotAcceptable
-          response.asJson shouldEqual jsonContentOf("errors/content-type.json", "expected" -> "text/plain")
-          response.headers should not contain varyHeader
+      givenATaggedFile(tag) { id =>
+        forAll(List("", "?rev=1", s"?tags=$tag")) { suffix =>
+          Get(s"/v1/files/org/proj/$id$suffix") ~> Accept(`video/*`) ~> asReader ~> routes ~> check {
+            response.status shouldEqual StatusCodes.NotAcceptable
+            response.asJson shouldEqual jsonContentOf("errors/content-type.json", "expected" -> "text/plain")
+            response.headers should not contain varyHeader
+          }
         }
       }
     }
 
     "fetch a file" in {
-      forAll(List(Accept(`*/*`), Accept(`text/*`))) { accept =>
-        forAll(
-          List("/v1/files/org/proj/file1", "/v1/resources/org/proj/_/file1", "/v1/resources/org/proj/file/file1")
-        ) { endpoint =>
-          Get(endpoint) ~> accept ~> asReader ~> routes ~> check {
-            status shouldEqual StatusCodes.OK
-            contentType.value shouldEqual `text/plain(UTF-8)`.value
-            val filename64 = "ZmlsZS1pZHgtMS50eHQ=" // file-idx-1.txt
-            header("Content-Disposition").value.value() shouldEqual
-              s"""attachment; filename="=?UTF-8?B?$filename64?=""""
-            response.asString shouldEqual content
-            response.headers should contain(varyHeader)
+      givenAFile { id =>
+        forAll(List(Accept(`*/*`), Accept(`text/*`))) { accept =>
+          forAll(
+            List(s"/v1/files/org/proj/$id", s"/v1/resources/org/proj/_/$id", s"/v1/resources/org/proj/file/$id")
+          ) { endpoint =>
+            Get(endpoint) ~> accept ~> asReader ~> routes ~> check {
+              status shouldEqual StatusCodes.OK
+              contentType.value shouldEqual `text/plain(UTF-8)`.value
+              header("Content-Disposition").value.value() shouldEqual
+                s"""attachment; filename="=?UTF-8?B?${base64encode(id)}?=""""
+              response.asString shouldEqual content
+              response.headers should contain(varyHeader)
+            }
           }
         }
       }
     }
 
     "fetch a file by rev and tag" in {
-      val endpoints = List(
-        s"/v1/files/$uuid/$uuid/file1",
-        s"/v1/resources/$uuid/$uuid/_/file1",
-        s"/v1/resources/$uuid/$uuid/file/file1",
-        "/v1/files/org/proj/file1",
-        "/v1/resources/org/proj/_/file1",
-        "/v1/resources/org/proj/file/file1",
-        s"/v1/files/org/proj/$file1Encoded",
-        s"/v1/resources/org/proj/_/$file1Encoded",
-        s"/v1/resources/org/proj/file/$file1Encoded"
-      )
-      forAll(endpoints) { endpoint =>
-        forAll(List("rev=1", "tag=mytag")) { param =>
-          Get(s"$endpoint?$param") ~> Accept(`*/*`) ~> asReader ~> routes ~> check {
-            status shouldEqual StatusCodes.OK
-            contentType.value shouldEqual `text/plain(UTF-8)`.value
-            val filename64 = "ZmlsZTIudHh0" // file2.txt
-            header("Content-Disposition").value.value() shouldEqual
-              s"""attachment; filename="=?UTF-8?B?$filename64?=""""
-            response.asString shouldEqual content
-            response.headers should contain(varyHeader)
+      givenATaggedFile(tag) { id =>
+        val endpoints = List(
+          s"/v1/files/$uuid/$uuid/$id",
+          s"/v1/resources/$uuid/$uuid/_/$id",
+          s"/v1/resources/$uuid/$uuid/file/$id",
+          s"/v1/files/org/proj/$id",
+          s"/v1/resources/org/proj/_/$id",
+          s"/v1/resources/org/proj/file/$id",
+          s"/v1/files/org/proj/${encodeId(id)}",
+          s"/v1/resources/org/proj/_/${encodeId(id)}",
+          s"/v1/resources/org/proj/file/${encodeId(id)}"
+        )
+        forAll(endpoints) { endpoint =>
+          forAll(List("rev=1", s"tag=$tag")) { param =>
+            Get(s"$endpoint?$param") ~> Accept(`*/*`) ~> asReader ~> routes ~> check {
+              status shouldEqual StatusCodes.OK
+              contentType.value shouldEqual `text/plain(UTF-8)`.value
+              header("Content-Disposition").value.value() shouldEqual
+                s"""attachment; filename="=?UTF-8?B?${base64encode(id)}?=""""
+              response.asString shouldEqual content
+              response.headers should contain(varyHeader)
+            }
           }
         }
       }
     }
 
     "fail to fetch a file metadata without resources/read permission" in {
-      val endpoints =
-        List("/v1/files/org/proj/file1", "/v1/files/org/proj/file1/tags", "/v1/resources/org/proj/_/file1/tags")
-      forAll(endpoints) { endpoint =>
-        forAll(List("", "?rev=1", "?tags=mytag")) { suffix =>
-          Get(s"$endpoint$suffix") ~> Accept(`application/ld+json`) ~> routes ~> check {
-            response.shouldBeForbidden
-            response.headers should not contain varyHeader
+      givenATaggedFile(tag) { id =>
+        val endpoints =
+          List(s"/v1/files/org/proj/$id", s"/v1/files/org/proj/$id/tags", s"/v1/resources/org/proj/_/$id/tags")
+        forAll(endpoints) { endpoint =>
+          forAll(List("", "?rev=1", s"?tags=$tag")) { suffix =>
+            Get(s"$endpoint$suffix") ~> Accept(`application/ld+json`) ~> routes ~> check {
+              response.shouldBeForbidden
+              response.headers should not contain varyHeader
+            }
           }
         }
       }
     }
 
     "fetch a file metadata" in {
-      Get("/v1/files/org/proj/file1") ~> Accept(`application/ld+json`) ~> asReader ~> routes ~> check {
-        status shouldEqual StatusCodes.OK
-        val attr = attributes("file-idx-1.txt")
-        response.asJson shouldEqual fileMetadata(projectRef, file1, attr, diskIdRev, rev = 4, createdBy = s3writer)
-        response.headers should contain(varyHeader)
+      givenAFile { id =>
+        Get(s"/v1/files/org/proj/$id") ~> Accept(`application/ld+json`) ~> asReader ~> routes ~> check {
+          status shouldEqual StatusCodes.OK
+          val attr = attributes(id)
+          response.asJson shouldEqual fileMetadata(projectRef, nxv + id, attr, diskIdRev)
+          response.headers should contain(varyHeader)
+        }
       }
     }
 
     "fetch a file metadata by rev and tag" in {
-      val attr      = attributes("file2.txt")
-      val endpoints = List(
-        s"/v1/files/$uuid/$uuid/file1",
-        s"/v1/resources/$uuid/$uuid/_/file1",
-        s"/v1/resources/$uuid/$uuid/file/file1",
-        "/v1/files/org/proj/file1",
-        "/v1/resources/org/proj/_/file1",
-        s"/v1/files/org/proj/$file1Encoded",
-        s"/v1/resources/org/proj/_/$file1Encoded"
-      )
-      forAll(endpoints) { endpoint =>
-        forAll(List("rev=1", "tag=mytag")) { param =>
-          Get(s"$endpoint?$param") ~> Accept(`application/ld+json`) ~> asReader ~> routes ~> check {
-            status shouldEqual StatusCodes.OK
-            response.asJson shouldEqual
-              fileMetadata(projectRef, file1, attr, s3IdRev, createdBy = s3writer, updatedBy = s3writer)
-            response.headers should contain(varyHeader)
+      givenATaggedFile(tag) { id =>
+        val attr      = attributes(id)
+        val endpoints = List(
+          s"/v1/files/$uuid/$uuid/$id",
+          s"/v1/resources/$uuid/$uuid/_/$id",
+          s"/v1/resources/$uuid/$uuid/file/$id",
+          s"/v1/files/org/proj/$id",
+          s"/v1/resources/org/proj/_/$id",
+          s"/v1/files/org/proj/${encodeId(id)}",
+          s"/v1/resources/org/proj/_/${encodeId(id)}"
+        )
+        forAll(endpoints) { endpoint =>
+          forAll(List("rev=1", s"tag=$tag")) { param =>
+            Get(s"$endpoint?$param") ~> Accept(`application/ld+json`) ~> asReader ~> routes ~> check {
+              status shouldEqual StatusCodes.OK
+              response.asJson shouldEqual
+                fileMetadata(projectRef, nxv + id, attr, diskIdRev)
+              response.headers should contain(varyHeader)
+            }
           }
         }
       }
     }
 
     "fetch the file tags" in {
-      Get("/v1/resources/org/proj/_/file1/tags?rev=1") ~> asReader ~> routes ~> check {
-        status shouldEqual StatusCodes.OK
-        response.asJson shouldEqual json"""{"tags": []}""".addContext(contexts.tags)
-      }
-      Get("/v1/files/org/proj/file1/tags") ~> asReader ~> routes ~> check {
-        status shouldEqual StatusCodes.OK
-        response.asJson shouldEqual json"""{"tags": [{"rev": 1, "tag": "mytag"}]}""".addContext(contexts.tags)
+      givenAFile { id =>
+        val taggingPayload = json"""{"tag": "$tag", "rev": 1}"""
+        Post(s"/v1/files/org/proj/$id/tags?rev=1", taggingPayload.toEntity) ~> asWriter ~> routes ~> check {
+          status shouldEqual StatusCodes.Created
+        }
+        Get(s"/v1/resources/org/proj/_/$id/tags?rev=1") ~> asReader ~> routes ~> check {
+          status shouldEqual StatusCodes.OK
+          response.asJson shouldEqual json"""{"tags": []}""".addContext(contexts.tags)
+        }
+        Get(s"/v1/files/org/proj/$id/tags") ~> asReader ~> routes ~> check {
+          status shouldEqual StatusCodes.OK
+          response.asJson shouldEqual json"""{"tags": [{"rev": 1, "tag": "$tag"}]}""".addContext(contexts.tags)
+        }
       }
     }
 
     "return not found if tag not found" in {
-      Get("/v1/files/org/proj/file1?tag=myother") ~> Accept(`application/ld+json`) ~> asReader ~> routes ~> check {
-        status shouldEqual StatusCodes.NotFound
-        response.asJson shouldEqual jsonContentOf("/errors/tag-not-found.json", "tag" -> "myother")
+      givenATaggedFile("mytag") { id =>
+        Get(s"/v1/files/org/proj/$id?tag=myother") ~> Accept(`application/ld+json`) ~> asReader ~> routes ~> check {
+          status shouldEqual StatusCodes.NotFound
+          response.asJson shouldEqual jsonContentOf("/errors/tag-not-found.json", "tag" -> "myother")
+        }
       }
     }
 
     "reject if provided rev and tag simultaneously" in {
-      Get("/v1/files/org/proj/file1?tag=mytag&rev=1") ~> Accept(`application/ld+json`) ~> asReader ~> routes ~> check {
-        status shouldEqual StatusCodes.BadRequest
-        response.asJson shouldEqual jsonContentOf("/errors/tag-and-rev-error.json")
+      givenATaggedFile(tag) { id =>
+        Get(s"/v1/files/org/proj/$id?tag=$tag&rev=1") ~> Accept(`application/ld+json`) ~> asReader ~> routes ~> check {
+          status shouldEqual StatusCodes.BadRequest
+          response.asJson shouldEqual jsonContentOf("/errors/tag-and-rev-error.json")
+        }
       }
     }
 
-    "delete a tag on file" in {
-      Delete("/v1/files/org/proj/file1/tags/mytag?rev=4") ~> asWriter ~> routes ~> check {
-        val attr = attributes("file-idx-1.txt")
+    def deleteTag(id: String, tag: String, rev: Int) =
+      Delete(s"/v1/files/org/proj/$id/tags/$tag?rev=$rev") ~> asWriter ~> routes ~> check {
+        val attr = attributes(s"$id")
         status shouldEqual StatusCodes.OK
-        response.asJson shouldEqual fileMetadata(projectRef, file1, attr, diskIdRev, rev = 5, createdBy = s3writer)
+        response.asJson shouldEqual fileMetadata(projectRef, nxv + id, attr, diskIdRev, rev = rev + 1)
+      }
+
+    "delete a tag on file" in {
+      givenATaggedFile(tag) { id =>
+        deleteTag(id, tag, 1)
       }
     }
 
     "not return the deleted tag" in {
-      Get("/v1/files/org/proj/file1/tags") ~> asReader ~> routes ~> check {
-        status shouldEqual StatusCodes.OK
-        response.asJson shouldEqual json"""{"tags": []}""".addContext(contexts.tags)
+      givenATaggedFile(tag) { id =>
+        deleteTag(id, tag, 1)
+        Get(s"/v1/files/org/proj/$id/tags") ~> asReader ~> routes ~> check {
+          status shouldEqual StatusCodes.OK
+          response.asJson shouldEqual json"""{"tags": []}""".addContext(contexts.tags)
+        }
       }
     }
 
     "fail to fetch file by the deleted tag" in {
-      Get("/v1/files/org/proj/file1?tag=mytag") ~> Accept(`application/ld+json`) ~> asReader ~> routes ~> check {
-        status shouldEqual StatusCodes.NotFound
-        response.asJson shouldEqual jsonContentOf("/errors/tag-not-found.json", "tag" -> "mytag")
+      givenATaggedFile(tag) { id =>
+        deleteTag(id, tag, 1)
+        Get(s"/v1/files/org/proj/$id?tag=$tag") ~> Accept(`application/ld+json`) ~> asReader ~> routes ~> check {
+          status shouldEqual StatusCodes.NotFound
+          response.asJson shouldEqual jsonContentOf("/errors/tag-not-found.json", "tag" -> "mytag")
+        }
       }
     }
 
     "redirect to fusion for the latest version if the Accept header is set to text/html" in {
-      Get("/v1/files/org/project/file1") ~> Accept(`text/html`) ~> routes ~> check {
-        response.status shouldEqual StatusCodes.SeeOther
-        response.header[Location].value.uri shouldEqual Uri("https://bbp.epfl.ch/nexus/web/org/project/resources/file1")
+      givenAFile { id =>
+        Get(s"/v1/files/org/project/$id") ~> Accept(`text/html`) ~> routes ~> check {
+          response.status shouldEqual StatusCodes.SeeOther
+          response.header[Location].value.uri shouldEqual Uri(
+            s"https://bbp.epfl.ch/nexus/web/org/project/resources/$id"
+          )
+        }
       }
     }
   }
@@ -572,6 +635,14 @@ class FilesRoutesSpec
     test(id)
   }
 
+  def givenATaggedFile(tag: String)(test: String => Assertion): Assertion = {
+    val id = genString()
+    Put(s"/v1/files/org/proj/$id?tag=$tag", entity(s"$id")) ~> asWriter ~> routes ~> check {
+      status shouldEqual StatusCodes.Created
+    }
+    test(id)
+  }
+
   def givenADeprecatedFile(test: String => Assertion): Assertion =
     givenAFile { id =>
       Delete(s"/v1/files/org/proj/$id?rev=1") ~> asWriter ~> routes ~> check {

From 1f93b34aecf2ca61c3c3bd3e80a91462476edde9 Mon Sep 17 00:00:00 2001
From: Oliver <20188437+olivergrabinski@users.noreply.github.com>
Date: Fri, 3 Nov 2023 17:53:01 +0100
Subject: [PATCH 09/22] Fix user naming

---
 .../delta/routes/ResourcesRoutesSpec.scala    | 20 +++++++++----------
 .../resources/resource-with-metadata.json     |  4 ++--
 2 files changed, 12 insertions(+), 12 deletions(-)

diff --git a/delta/app/src/test/scala/ch/epfl/bluebrain/nexus/delta/routes/ResourcesRoutesSpec.scala b/delta/app/src/test/scala/ch/epfl/bluebrain/nexus/delta/routes/ResourcesRoutesSpec.scala
index 76fcf54d40..db8797dce4 100644
--- a/delta/app/src/test/scala/ch/epfl/bluebrain/nexus/delta/routes/ResourcesRoutesSpec.scala
+++ b/delta/app/src/test/scala/ch/epfl/bluebrain/nexus/delta/routes/ResourcesRoutesSpec.scala
@@ -29,7 +29,7 @@ import ch.epfl.bluebrain.nexus.delta.sdk.resources.model.ResourceRejection.Proje
 import ch.epfl.bluebrain.nexus.delta.sdk.resources.{Resources, ResourcesConfig, ResourcesImpl, ValidateResource}
 import ch.epfl.bluebrain.nexus.delta.sdk.schemas.model.Schema
 import ch.epfl.bluebrain.nexus.delta.sdk.utils.BaseRouteSpec
-import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.{Anonymous, Authenticated, Group, Subject}
+import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.{Anonymous, Authenticated, Group, Subject, User}
 import ch.epfl.bluebrain.nexus.delta.sourcing.model.Tag.UserTag
 import ch.epfl.bluebrain.nexus.delta.sourcing.model.{ProjectRef, ResourceRef}
 import ch.epfl.bluebrain.nexus.testkit.bio.IOFromMap
@@ -44,16 +44,16 @@ class ResourcesRoutesSpec extends BaseRouteSpec with IOFromMap with CatsIOValues
   private val uuid                  = UUID.randomUUID()
   implicit private val uuidF: UUIDF = UUIDF.fixed(uuid)
 
-  private val reader = alice
-  private val writer = bob
+  private val reader = User("reader", realm)
+  private val writer = User("writer", realm)
 
-  implicit private val callerAlice: Caller =
-    Caller(alice, Set(alice, Anonymous, Authenticated(realm), Group("group", realm)))
-  implicit private val callerBob: Caller   =
-    Caller(bob, Set(bob, Anonymous, Authenticated(realm), Group("group", realm)))
+  implicit private val callerReader: Caller =
+    Caller(reader, Set(reader, Anonymous, Authenticated(realm), Group("group", realm)))
+  implicit private val callerWriter: Caller    =
+    Caller(writer, Set(writer, Anonymous, Authenticated(realm), Group("group", realm)))
 
-  private val asReader = addCredentials(OAuth2BearerToken("alice"))
-  private val asWriter = addCredentials(OAuth2BearerToken("bob"))
+  private val asReader = addCredentials(OAuth2BearerToken("reader"))
+  private val asWriter = addCredentials(OAuth2BearerToken("writer"))
 
   private val am           = ApiMappings("nxv" -> nxv.base, "Person" -> schema.Person)
   private val projBase     = nxv.base
@@ -114,7 +114,7 @@ class ResourcesRoutesSpec extends BaseRouteSpec with IOFromMap with CatsIOValues
     (
       Route.seal(
         ResourcesRoutes(
-          IdentitiesDummy(callerAlice, callerBob),
+          IdentitiesDummy(callerReader, callerWriter),
           aclCheck,
           resources,
           DeltaSchemeDirectives(
diff --git a/delta/sdk/src/test/resources/resources/resource-with-metadata.json b/delta/sdk/src/test/resources/resources/resource-with-metadata.json
index c26cc970de..0d359f2585 100644
--- a/delta/sdk/src/test/resources/resources/resource-with-metadata.json
+++ b/delta/sdk/src/test/resources/resources/resource-with-metadata.json
@@ -7,7 +7,7 @@
   "number": 24,
   "_constrainedBy": "https://bluebrain.github.io/nexus/schemas/unconstrained.json",
   "_createdAt": "1970-01-01T00:00:00Z",
-  "_createdBy": "http://localhost/v1/realms/wonderland/users/bob",
+  "_createdBy": "http://localhost/v1/realms/wonderland/users/writer",
   "_deprecated": false,
   "_incoming": "{{self}}/incoming",
   "_outgoing": "{{self}}/outgoing",
@@ -16,5 +16,5 @@
   "_schemaProject": "http://localhost/v1/projects/myorg/myproject",
   "_self": "{{self}}",
   "_updatedAt": "1970-01-01T00:00:00Z",
-  "_updatedBy": "http://localhost/v1/realms/wonderland/users/bob"
+  "_updatedBy": "http://localhost/v1/realms/wonderland/users/writer"
 }
\ No newline at end of file

From e40e5ed35f258fa7f85a53d0e2571405a5b09f34 Mon Sep 17 00:00:00 2001
From: Oliver <20188437+olivergrabinski@users.noreply.github.com>
Date: Fri, 3 Nov 2023 18:58:11 +0100
Subject: [PATCH 10/22] Add integration tests

---
 .../epfl/bluebrain/nexus/tests/Identity.scala |  6 +-
 .../bluebrain/nexus/tests/kg/FilesSpec.scala  | 97 +++++++++++++++++++
 2 files changed, 102 insertions(+), 1 deletion(-)
 create mode 100644 tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/kg/FilesSpec.scala

diff --git a/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/Identity.scala b/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/Identity.scala
index b1a18e5878..73c78d863e 100644
--- a/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/Identity.scala
+++ b/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/Identity.scala
@@ -98,7 +98,11 @@ object Identity extends TestHelpers {
     val Mickey = UserCredentials(genString(), genString(), testRealm)
   }
 
+  object files {
+    val Writer = UserCredentials(genString(), genString(), testRealm)
+  }
+
   lazy val allUsers =
-    userPermissions.UserWithNoPermissions :: userPermissions.UserWithPermissions :: acls.Marge :: archives.Tweety :: compositeviews.Jerry :: events.BugsBunny :: listings.Bob :: listings.Alice :: aggregations.Charlie :: aggregations.Rose :: orgs.Fry :: orgs.Leela :: projects.Bojack :: projects.PrincessCarolyn :: resources.Rick :: resources.Morty :: storages.Coyote :: views.ScoobyDoo :: mash.Radar :: supervision.Mickey :: Nil
+    userPermissions.UserWithNoPermissions :: userPermissions.UserWithPermissions :: acls.Marge :: archives.Tweety :: compositeviews.Jerry :: events.BugsBunny :: listings.Bob :: listings.Alice :: aggregations.Charlie :: aggregations.Rose :: orgs.Fry :: orgs.Leela :: projects.Bojack :: projects.PrincessCarolyn :: resources.Rick :: resources.Morty :: storages.Coyote :: views.ScoobyDoo :: mash.Radar :: supervision.Mickey :: files.Writer :: Nil
 
 }
diff --git a/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/kg/FilesSpec.scala b/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/kg/FilesSpec.scala
new file mode 100644
index 0000000000..f4ed740720
--- /dev/null
+++ b/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/kg/FilesSpec.scala
@@ -0,0 +1,97 @@
+package ch.epfl.bluebrain.nexus.tests.kg
+
+import akka.http.scaladsl.model.{ContentTypes, StatusCodes}
+import ch.epfl.bluebrain.nexus.tests.BaseIntegrationSpec
+import ch.epfl.bluebrain.nexus.tests.Identity.files.Writer
+import ch.epfl.bluebrain.nexus.tests.Optics.listing._total
+import io.circe.Json
+import org.scalatest.Assertion
+
+class FilesSpec extends BaseIntegrationSpec {
+
+  private val org        = genString()
+  private val project    = genString()
+  private val projectRef = s"$org/$project"
+
+  private val dummyJson = json"""{ "random": "content" }"""
+
+  override def beforeAll(): Unit = {
+    super.beforeAll()
+    createProjects(Writer, org, project).accepted
+  }
+
+  "File deprecation" should {
+
+    "remove the deprecated file from the listing" in {
+      givenAFile { id =>
+        eventually { assertFileIsInListing(id) }
+        deprecateFile(id, rev = 1)
+        eventually { assertFileNotInListing(id) }
+      }
+    }
+
+  }
+
+  "Files undeprecation" should {
+
+    "reindex the previously deprecated file" in {
+      givenADeprecatedFile { id =>
+        eventually { assertFileNotInListing(id) }
+        undeprecateFile(id, rev = 2)
+        eventually { assertFileIsInListing(id) }
+      }
+    }
+
+  }
+
+  private def assertListingTotal(id: String, expectedTotal: Int) =
+    deltaClient.get[Json](s"/files/$projectRef?locate=$id&deprecated=false", Writer) { (json, response) =>
+      response.status shouldEqual StatusCodes.OK
+      _total.getOption(json) should contain(expectedTotal)
+    }
+
+  private def assertFileIsInListing(id: String) =
+    assertListingTotal(id, 1)
+
+  private def assertFileNotInListing(id: String) =
+    assertListingTotal(id, 0)
+
+  /** Provides a file in the default storage */
+  private def givenAFile(assertion: String => Assertion): Assertion = {
+    val id = genString()
+    deltaClient
+      .uploadFile[Json](
+        s"/files/$projectRef/$id",
+        "file content",
+        ContentTypes.`text/plain(UTF-8)`,
+        s"$id.txt",
+        Writer
+      ) { (_, response) =>
+        response.status shouldEqual StatusCodes.Created
+      }
+      .accepted
+    assertion(id)
+  }
+
+  private def deprecateFile(id: String, rev: Int): Assertion =
+    deltaClient
+      .delete[Json](s"/files/$projectRef/$id?rev=$rev", Writer) { (_, response) =>
+        response.status shouldEqual StatusCodes.OK
+      }
+      .accepted
+
+  private def givenADeprecatedFile(assertion: String => Assertion): Assertion = {
+    givenAFile { id =>
+      deprecateFile(id, 1)
+      assertion(id)
+    }
+  }
+
+  private def undeprecateFile(id: String, rev: Int): Assertion =
+    deltaClient
+      .put[Json](s"/files/$projectRef/$id/undeprecate?rev=$rev", dummyJson, Writer) { (_, response) =>
+        response.status shouldEqual StatusCodes.OK
+      }
+      .accepted
+
+}

From f5cc6fb9584be68b2300ac7336a24244e5a7501e Mon Sep 17 00:00:00 2001
From: Oliver <20188437+olivergrabinski@users.noreply.github.com>
Date: Mon, 6 Nov 2023 08:26:23 +0100
Subject: [PATCH 11/22] scalafmt

---
 .../epfl/bluebrain/nexus/delta/routes/ResourcesRoutesSpec.scala | 2 +-
 .../delta/plugins/storage/files/routes/FilesRoutesSpec.scala    | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/delta/app/src/test/scala/ch/epfl/bluebrain/nexus/delta/routes/ResourcesRoutesSpec.scala b/delta/app/src/test/scala/ch/epfl/bluebrain/nexus/delta/routes/ResourcesRoutesSpec.scala
index db8797dce4..3763dc138a 100644
--- a/delta/app/src/test/scala/ch/epfl/bluebrain/nexus/delta/routes/ResourcesRoutesSpec.scala
+++ b/delta/app/src/test/scala/ch/epfl/bluebrain/nexus/delta/routes/ResourcesRoutesSpec.scala
@@ -49,7 +49,7 @@ class ResourcesRoutesSpec extends BaseRouteSpec with IOFromMap with CatsIOValues
 
   implicit private val callerReader: Caller =
     Caller(reader, Set(reader, Anonymous, Authenticated(realm), Group("group", realm)))
-  implicit private val callerWriter: Caller    =
+  implicit private val callerWriter: Caller =
     Caller(writer, Set(writer, Anonymous, Authenticated(realm), Group("group", realm)))
 
   private val asReader = addCredentials(OAuth2BearerToken("reader"))
diff --git a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala
index bd9e4240b9..e1a9887edb 100644
--- a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala
+++ b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala
@@ -227,7 +227,7 @@ class FilesRoutesSpec
           status shouldEqual StatusCodes.Created
           val attr      = attributes("fileTagged.txt", id = uuid2)
           val expected  = fileMetadata(projectRef, fileTagged, attr, s3IdRev, createdBy = s3writer, updatedBy = s3writer)
-          val userTag = UserTag.unsafe(tag)
+          val userTag   = UserTag.unsafe(tag)
           val fileByTag = files.fetch(FileId(generatedId2, userTag, projectRef)).accepted
           response.asJson shouldEqual expected
           fileByTag.value.tags.tags should contain(userTag)

From ce5a82526597a907b5b608350888c8a04e498804 Mon Sep 17 00:00:00 2001
From: Oliver <20188437+olivergrabinski@users.noreply.github.com>
Date: Mon, 6 Nov 2023 08:36:09 +0100
Subject: [PATCH 12/22] Add tests to FilesStmSpec for undeprecation

---
 .../plugins/storage/files/FilesStmSpec.scala  | 27 ++++++++++++++++++-
 1 file changed, 26 insertions(+), 1 deletion(-)

diff --git a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/FilesStmSpec.scala b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/FilesStmSpec.scala
index 48dae7c034..30c346e8c9 100644
--- a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/FilesStmSpec.scala
+++ b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/FilesStmSpec.scala
@@ -6,7 +6,7 @@ import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model.Digest.{Compute
 import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model.FileAttributes.FileAttributesOrigin.Client
 import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model.FileCommand._
 import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model.FileEvent._
-import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model.FileRejection.{DigestAlreadyComputed, DigestNotComputed, FileIsDeprecated, FileNotFound, IncorrectRev, ResourceAlreadyExists, RevisionNotFound}
+import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model.FileRejection.{DigestAlreadyComputed, DigestNotComputed, FileIsDeprecated, FileIsNotDeprecated, FileNotFound, IncorrectRev, ResourceAlreadyExists, RevisionNotFound}
 import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model.{Digest, FileAttributes}
 import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.StorageFixtures
 import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.model.DigestAlgorithm
@@ -98,6 +98,12 @@ class FilesStmSpec extends CatsEffectSpec with FileFixtures with StorageFixtures
           FileDeprecated(id, projectRef, storageRef, DiskStorageType, 3, epoch, alice)
       }
 
+      "create a new event from a UndeprecateFile command" in {
+        val current = FileGen.state(id, projectRef, storageRef, attributes, rev = 2, deprecated = true)
+        evaluate(Some(current), UndeprecateFile(id, projectRef, 2, alice)).accepted shouldEqual
+          FileUndeprecated(id, projectRef, storageRef, DiskStorageType, 3, epoch, alice)
+      }
+
       "reject with IncorrectRev" in {
         val current  = FileGen.state(id, projectRef, storageRef, attributes)
         val commands = List(
@@ -143,6 +149,11 @@ class FilesStmSpec extends CatsEffectSpec with FileFixtures with StorageFixtures
         }
       }
 
+      "reject with FileIsNotDeprecated" in {
+        val current = FileGen.state(id, projectRef, storageRef, attributes, deprecated = false)
+        evaluate(Some(current), UndeprecateFile(id, projectRef, 1, alice)).rejectedWith[FileIsNotDeprecated]
+      }
+
       "reject with RevisionNotFound" in {
         val current = FileGen.state(id, projectRef, storageRef, attributes)
         evaluate(Some(current), TagFile(id, projectRef, targetRev = 3, myTag, 1, alice)).rejected shouldEqual
@@ -209,6 +220,20 @@ class FilesStmSpec extends CatsEffectSpec with FileFixtures with StorageFixtures
           updatedBy = alice
         )
       }
+
+      "from a new FileUndeprecated event" in {
+        val event   = FileUndeprecated(id, projectRef, storageRef, DiskStorageType, 2, time2, alice)
+        val current = FileGen.state(id, projectRef, storageRef, attributes, deprecated = true)
+
+        next(None, event) shouldEqual None
+
+        next(Some(current), event).value shouldEqual current.copy(
+          rev = 2,
+          deprecated = false,
+          updatedAt = time2,
+          updatedBy = alice
+        )
+      }
     }
   }
 

From 5cc9786189d0e3b6db3b3aa568606be16bd25c22 Mon Sep 17 00:00:00 2001
From: Oliver <20188437+olivergrabinski@users.noreply.github.com>
Date: Mon, 6 Nov 2023 08:36:40 +0100
Subject: [PATCH 13/22] Update version of storage used in tests

---
 .../nexus/testkit/remotestorage/RemoteStorageContainer.scala    | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/delta/testkit/src/main/scala/ch/epfl/bluebrain/nexus/testkit/remotestorage/RemoteStorageContainer.scala b/delta/testkit/src/main/scala/ch/epfl/bluebrain/nexus/testkit/remotestorage/RemoteStorageContainer.scala
index 93a942a803..48604360bf 100644
--- a/delta/testkit/src/main/scala/ch/epfl/bluebrain/nexus/testkit/remotestorage/RemoteStorageContainer.scala
+++ b/delta/testkit/src/main/scala/ch/epfl/bluebrain/nexus/testkit/remotestorage/RemoteStorageContainer.scala
@@ -7,7 +7,7 @@ import org.testcontainers.utility.DockerImageName
 import java.nio.file.Path
 
 class RemoteStorageContainer(rootVolume: Path)
-    extends GenericContainer[RemoteStorageContainer](DockerImageName.parse("bluebrain/nexus-storage:1.7.0")) {
+    extends GenericContainer[RemoteStorageContainer](DockerImageName.parse("bluebrain/nexus-storage:1.8.0-M12")) {
 
   addEnv("JAVA_OPTS", "-Xmx256m -Dconfig.override_with_env_vars=true")
   addEnv("CONFIG_FORCE_app_subject_anonymous", "true")

From 9275fb75722d9b256674e0aaed2743a5fbd76fbd Mon Sep 17 00:00:00 2001
From: Oliver <20188437+olivergrabinski@users.noreply.github.com>
Date: Mon, 6 Nov 2023 09:14:27 +0100
Subject: [PATCH 14/22] Move `fileMetadata`

---
 .../storage/files/routes/FilesRoutesSpec.scala  | 17 +++++++++++++++++
 1 file changed, 17 insertions(+)

diff --git a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala
index e1a9887edb..f4db69e41f 100644
--- a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala
+++ b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala
@@ -38,6 +38,7 @@ import ch.epfl.bluebrain.nexus.delta.sdk.utils.BaseRouteSpec
 import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.{Anonymous, Authenticated, Group, Subject, User}
 import ch.epfl.bluebrain.nexus.delta.sourcing.model.Tag.UserTag
 import ch.epfl.bluebrain.nexus.delta.sourcing.model.{Label, ProjectRef, ResourceRef}
+import ch.epfl.bluebrain.nexus.testkit.TestHelpers.jsonContentOf
 import ch.epfl.bluebrain.nexus.testkit.bio.IOFromMap
 import ch.epfl.bluebrain.nexus.testkit.scalatest.ce.CatsIOValues
 import io.circe.Json
@@ -661,6 +662,22 @@ class FilesRoutesSpec
       deprecated: Boolean = false,
       createdBy: Subject = callerWriter.subject,
       updatedBy: Subject = callerWriter.subject
+  )(implicit baseUri: BaseUri): Json =
+    fileMetadata(project, id, attributes, storage, storageType, rev, deprecated, createdBy, updatedBy)
+
+}
+
+object FilesRoutesSpec {
+  def fileMetadata(
+      project: ProjectRef,
+      id: Iri,
+      attributes: FileAttributes,
+      storage: ResourceRef.Revision,
+      storageType: StorageType = StorageType.DiskStorage,
+      rev: Int = 1,
+      deprecated: Boolean = false,
+      createdBy: Subject,
+      updatedBy: Subject
   )(implicit baseUri: BaseUri): Json =
     jsonContentOf(
       "files/file-route-metadata-response.json",

From f53d3c493e54a0d436c41fc4a2ed59073f7dd87a Mon Sep 17 00:00:00 2001
From: Oliver <20188437+olivergrabinski@users.noreply.github.com>
Date: Mon, 6 Nov 2023 09:49:10 +0100
Subject: [PATCH 15/22] Fix RemoteStorageClientSpec

---
 .../operations/remote/client/RemoteStorageClientSpec.scala    | 2 +-
 .../nexus/testkit/remotestorage/RemoteStorageContainer.scala  | 4 ++--
 .../nexus/testkit/remotestorage/RemoteStorageDocker.scala     | 4 +++-
 3 files changed, 6 insertions(+), 4 deletions(-)

diff --git a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/operations/remote/client/RemoteStorageClientSpec.scala b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/operations/remote/client/RemoteStorageClientSpec.scala
index 1c1118cbc6..01a8c82576 100644
--- a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/operations/remote/client/RemoteStorageClientSpec.scala
+++ b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/operations/remote/client/RemoteStorageClientSpec.scala
@@ -58,7 +58,7 @@ class RemoteStorageClientSpec(docker: RemoteStorageDocker)
     )
 
     "fetch the service description" in eventually {
-      client.serviceDescription(baseUri).accepted shouldEqual ServiceDescription(Name.unsafe("remoteStorage"), "1.7.0")
+      client.serviceDescription(baseUri).accepted shouldEqual ServiceDescription(Name.unsafe("remoteStorage"), docker.storageVersion)
     }
 
     "check if a bucket exists" in {
diff --git a/delta/testkit/src/main/scala/ch/epfl/bluebrain/nexus/testkit/remotestorage/RemoteStorageContainer.scala b/delta/testkit/src/main/scala/ch/epfl/bluebrain/nexus/testkit/remotestorage/RemoteStorageContainer.scala
index 48604360bf..fc9aa8814f 100644
--- a/delta/testkit/src/main/scala/ch/epfl/bluebrain/nexus/testkit/remotestorage/RemoteStorageContainer.scala
+++ b/delta/testkit/src/main/scala/ch/epfl/bluebrain/nexus/testkit/remotestorage/RemoteStorageContainer.scala
@@ -6,8 +6,8 @@ import org.testcontainers.utility.DockerImageName
 
 import java.nio.file.Path
 
-class RemoteStorageContainer(rootVolume: Path)
-    extends GenericContainer[RemoteStorageContainer](DockerImageName.parse("bluebrain/nexus-storage:1.8.0-M12")) {
+class RemoteStorageContainer(storageVersion: String, rootVolume: Path)
+    extends GenericContainer[RemoteStorageContainer](DockerImageName.parse(s"bluebrain/nexus-storage:$storageVersion")) {
 
   addEnv("JAVA_OPTS", "-Xmx256m -Dconfig.override_with_env_vars=true")
   addEnv("CONFIG_FORCE_app_subject_anonymous", "true")
diff --git a/delta/testkit/src/main/scala/ch/epfl/bluebrain/nexus/testkit/remotestorage/RemoteStorageDocker.scala b/delta/testkit/src/main/scala/ch/epfl/bluebrain/nexus/testkit/remotestorage/RemoteStorageDocker.scala
index 3547117fcb..526542614a 100644
--- a/delta/testkit/src/main/scala/ch/epfl/bluebrain/nexus/testkit/remotestorage/RemoteStorageDocker.scala
+++ b/delta/testkit/src/main/scala/ch/epfl/bluebrain/nexus/testkit/remotestorage/RemoteStorageDocker.scala
@@ -13,8 +13,10 @@ trait RemoteStorageDocker extends BeforeAndAfterAll { this: Suite =>
   private val rwx             = PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwxrwxrwx"))
   private val tmpFolder: Path = Files.createTempDirectory("root", rwx)
 
+  val storageVersion: String = "1.8.0-M12"
+
   protected val container: RemoteStorageContainer =
-    new RemoteStorageContainer(tmpFolder)
+    new RemoteStorageContainer(storageVersion, tmpFolder)
       .withReuse(false)
       .withStartupTimeout(60.seconds.toJava)
 

From 02a60adfd1275b9f6eb8fb4ad19e86a307a0a7b4 Mon Sep 17 00:00:00 2001
From: Oliver <20188437+olivergrabinski@users.noreply.github.com>
Date: Mon, 6 Nov 2023 09:52:18 +0100
Subject: [PATCH 16/22] Fix FileRoutesSpec

---
 .../delta/plugins/storage/files/routes/FilesRoutesSpec.scala    | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala
index f4db69e41f..6582519d6c 100644
--- a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala
+++ b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala
@@ -663,7 +663,7 @@ class FilesRoutesSpec
       createdBy: Subject = callerWriter.subject,
       updatedBy: Subject = callerWriter.subject
   )(implicit baseUri: BaseUri): Json =
-    fileMetadata(project, id, attributes, storage, storageType, rev, deprecated, createdBy, updatedBy)
+    FilesRoutesSpec.fileMetadata(project, id, attributes, storage, storageType, rev, deprecated, createdBy, updatedBy)
 
 }
 

From 1b2b7e6b86b43c4c732bf1cf106f10dd532e0fd3 Mon Sep 17 00:00:00 2001
From: Oliver <20188437+olivergrabinski@users.noreply.github.com>
Date: Mon, 6 Nov 2023 09:56:38 +0100
Subject: [PATCH 17/22] scalafmt

---
 .../nexus/testkit/remotestorage/RemoteStorageContainer.scala  | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/delta/testkit/src/main/scala/ch/epfl/bluebrain/nexus/testkit/remotestorage/RemoteStorageContainer.scala b/delta/testkit/src/main/scala/ch/epfl/bluebrain/nexus/testkit/remotestorage/RemoteStorageContainer.scala
index fc9aa8814f..a62b054b49 100644
--- a/delta/testkit/src/main/scala/ch/epfl/bluebrain/nexus/testkit/remotestorage/RemoteStorageContainer.scala
+++ b/delta/testkit/src/main/scala/ch/epfl/bluebrain/nexus/testkit/remotestorage/RemoteStorageContainer.scala
@@ -7,7 +7,9 @@ import org.testcontainers.utility.DockerImageName
 import java.nio.file.Path
 
 class RemoteStorageContainer(storageVersion: String, rootVolume: Path)
-    extends GenericContainer[RemoteStorageContainer](DockerImageName.parse(s"bluebrain/nexus-storage:$storageVersion")) {
+    extends GenericContainer[RemoteStorageContainer](
+      DockerImageName.parse(s"bluebrain/nexus-storage:$storageVersion")
+    ) {
 
   addEnv("JAVA_OPTS", "-Xmx256m -Dconfig.override_with_env_vars=true")
   addEnv("CONFIG_FORCE_app_subject_anonymous", "true")

From 89148d5aba55c80f16d69f0716ac662674e93a87 Mon Sep 17 00:00:00 2001
From: Oliver <20188437+olivergrabinski@users.noreply.github.com>
Date: Mon, 6 Nov 2023 10:01:34 +0100
Subject: [PATCH 18/22] scalafmt

---
 .../operations/remote/client/RemoteStorageClientSpec.scala   | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/operations/remote/client/RemoteStorageClientSpec.scala b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/operations/remote/client/RemoteStorageClientSpec.scala
index 01a8c82576..f171deb18d 100644
--- a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/operations/remote/client/RemoteStorageClientSpec.scala
+++ b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/operations/remote/client/RemoteStorageClientSpec.scala
@@ -58,7 +58,10 @@ class RemoteStorageClientSpec(docker: RemoteStorageDocker)
     )
 
     "fetch the service description" in eventually {
-      client.serviceDescription(baseUri).accepted shouldEqual ServiceDescription(Name.unsafe("remoteStorage"), docker.storageVersion)
+      client.serviceDescription(baseUri).accepted shouldEqual ServiceDescription(
+        Name.unsafe("remoteStorage"),
+        docker.storageVersion
+      )
     }
 
     "check if a bucket exists" in {

From 0459fa48ccdd38e95b8f8cd7c738146365783a0f Mon Sep 17 00:00:00 2001
From: Oliver <20188437+olivergrabinski@users.noreply.github.com>
Date: Mon, 6 Nov 2023 12:22:56 +0100
Subject: [PATCH 19/22] Update integration test

---
 .../scala/ch/epfl/bluebrain/nexus/tests/kg/FilesSpec.scala    | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/kg/FilesSpec.scala b/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/kg/FilesSpec.scala
index f4ed740720..80f60dce7e 100644
--- a/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/kg/FilesSpec.scala
+++ b/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/kg/FilesSpec.scala
@@ -24,7 +24,6 @@ class FilesSpec extends BaseIntegrationSpec {
 
     "remove the deprecated file from the listing" in {
       givenAFile { id =>
-        eventually { assertFileIsInListing(id) }
         deprecateFile(id, rev = 1)
         eventually { assertFileNotInListing(id) }
       }
@@ -36,7 +35,6 @@ class FilesSpec extends BaseIntegrationSpec {
 
     "reindex the previously deprecated file" in {
       givenADeprecatedFile { id =>
-        eventually { assertFileNotInListing(id) }
         undeprecateFile(id, rev = 2)
         eventually { assertFileIsInListing(id) }
       }
@@ -70,6 +68,7 @@ class FilesSpec extends BaseIntegrationSpec {
         response.status shouldEqual StatusCodes.Created
       }
       .accepted
+    eventually { assertFileIsInListing(id) }
     assertion(id)
   }
 
@@ -83,6 +82,7 @@ class FilesSpec extends BaseIntegrationSpec {
   private def givenADeprecatedFile(assertion: String => Assertion): Assertion = {
     givenAFile { id =>
       deprecateFile(id, 1)
+      eventually { assertFileNotInListing(id) }
       assertion(id)
     }
   }

From ac87f61188feead08b15b93db35b4b748fa6bffc Mon Sep 17 00:00:00 2001
From: Oliver <20188437+olivergrabinski@users.noreply.github.com>
Date: Mon, 6 Nov 2023 14:16:46 +0100
Subject: [PATCH 20/22] User other `fail` method

---
 .../bluebrain/nexus/testkit/scalatest/ce/CatsIOValues.scala     | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/delta/testkit/src/main/scala/ch/epfl/bluebrain/nexus/testkit/scalatest/ce/CatsIOValues.scala b/delta/testkit/src/main/scala/ch/epfl/bluebrain/nexus/testkit/scalatest/ce/CatsIOValues.scala
index 155742eb7f..e09c98095d 100644
--- a/delta/testkit/src/main/scala/ch/epfl/bluebrain/nexus/testkit/scalatest/ce/CatsIOValues.scala
+++ b/delta/testkit/src/main/scala/ch/epfl/bluebrain/nexus/testkit/scalatest/ce/CatsIOValues.scala
@@ -14,7 +14,7 @@ trait CatsIOValues {
   implicit final class CatsIOValuesOps[A](private val io: IO[A]) {
     def accepted(implicit pos: source.Position): A = {
       io.attempt.unsafeRunTimed(45.seconds).getOrElse(fail("IO timed out during .accepted call")) match {
-        case Left(e)      => fail(s"IO failed when it was expected to succeed. Failure details: $e")
+        case Left(e)      => fail(s"IO failed when it was expected to succeed.", e)
         case Right(value) => value
       }
     }

From 1396655d0475626462fb2db44134a74d1571f7d7 Mon Sep 17 00:00:00 2001
From: Oliver <20188437+olivergrabinski@users.noreply.github.com>
Date: Mon, 6 Nov 2023 14:17:24 +0100
Subject: [PATCH 21/22] Inline json errors

---
 .../errors/file-is-not-deprecated.json        |  5 ----
 .../files/errors/already-exists.json          |  5 ----
 .../files/routes/FilesRoutesSpec.scala        |  7 ++++--
 .../testkit/errors/files/FileErrors.scala     | 25 +++++++++++++++++++
 4 files changed, 30 insertions(+), 12 deletions(-)
 delete mode 100644 delta/plugins/storage/src/test/resources/errors/file-is-not-deprecated.json
 delete mode 100644 delta/plugins/storage/src/test/resources/files/errors/already-exists.json
 create mode 100644 delta/testkit/src/main/scala/ch/epfl/bluebrain/nexus/testkit/errors/files/FileErrors.scala

diff --git a/delta/plugins/storage/src/test/resources/errors/file-is-not-deprecated.json b/delta/plugins/storage/src/test/resources/errors/file-is-not-deprecated.json
deleted file mode 100644
index 2a749bfbc7..0000000000
--- a/delta/plugins/storage/src/test/resources/errors/file-is-not-deprecated.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
-  "@context" : "https://bluebrain.github.io/nexus/contexts/error.json",
-  "@type" : "FileIsNotDeprecated",
-  "reason" : "File '{{id}}' is not deprecated."
-}
\ No newline at end of file
diff --git a/delta/plugins/storage/src/test/resources/files/errors/already-exists.json b/delta/plugins/storage/src/test/resources/files/errors/already-exists.json
deleted file mode 100644
index f6649ca685..0000000000
--- a/delta/plugins/storage/src/test/resources/files/errors/already-exists.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
-  "@context": "https://bluebrain.github.io/nexus/contexts/error.json",
-  "@type": "ResourceAlreadyExists",
-  "reason": "Resource '{{id}}' already exists in project 'org/proj'."
-}
\ No newline at end of file
diff --git a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala
index 6582519d6c..39ceb150b1 100644
--- a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala
+++ b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala
@@ -40,6 +40,7 @@ import ch.epfl.bluebrain.nexus.delta.sourcing.model.Tag.UserTag
 import ch.epfl.bluebrain.nexus.delta.sourcing.model.{Label, ProjectRef, ResourceRef}
 import ch.epfl.bluebrain.nexus.testkit.TestHelpers.jsonContentOf
 import ch.epfl.bluebrain.nexus.testkit.bio.IOFromMap
+import ch.epfl.bluebrain.nexus.testkit.errors.files.FileErrors.{fileAlreadyExistsError, fileIsNotDeprecatedError}
 import ch.epfl.bluebrain.nexus.testkit.scalatest.ce.CatsIOValues
 import io.circe.Json
 import org.scalatest._
@@ -240,7 +241,7 @@ class FilesRoutesSpec
       givenAFile { id =>
         Put(s"/v1/files/org/proj/$id", entity()) ~> asWriter ~> routes ~> check {
           status shouldEqual StatusCodes.Conflict
-          response.asJson shouldEqual jsonContentOf("/files/errors/already-exists.json", "id" -> (nxv + id))
+          response.asJson shouldEqual fileAlreadyExistsError(nxvBase(id))
         }
       }
     }
@@ -411,7 +412,7 @@ class FilesRoutesSpec
       givenAFile { id =>
         Put(s"/v1/files/org/proj/$id/undeprecate?rev=1") ~> asWriter ~> routes ~> check {
           status shouldEqual StatusCodes.BadRequest
-          response.asJson shouldEqual jsonContentOf("/errors/file-is-not-deprecated.json", "id" -> (nxv + id))
+          response.asJson shouldEqual fileIsNotDeprecatedError(nxvBase(id))
         }
       }
     }
@@ -665,6 +666,8 @@ class FilesRoutesSpec
   )(implicit baseUri: BaseUri): Json =
     FilesRoutesSpec.fileMetadata(project, id, attributes, storage, storageType, rev, deprecated, createdBy, updatedBy)
 
+  private def nxvBase(id: String): String = (nxv + id).toString
+
 }
 
 object FilesRoutesSpec {
diff --git a/delta/testkit/src/main/scala/ch/epfl/bluebrain/nexus/testkit/errors/files/FileErrors.scala b/delta/testkit/src/main/scala/ch/epfl/bluebrain/nexus/testkit/errors/files/FileErrors.scala
new file mode 100644
index 0000000000..cadc9bffe5
--- /dev/null
+++ b/delta/testkit/src/main/scala/ch/epfl/bluebrain/nexus/testkit/errors/files/FileErrors.scala
@@ -0,0 +1,25 @@
+package ch.epfl.bluebrain.nexus.testkit.errors.files
+
+import ch.epfl.bluebrain.nexus.testkit.CirceLiteral.circeLiteralSyntax
+
+object FileErrors {
+
+  def fileIsNotDeprecatedError(id: String) =
+    json"""
+      {
+        "@context" : "https://bluebrain.github.io/nexus/contexts/error.json",
+        "@type" : "FileIsNotDeprecated",
+        "reason" : "File '$id' is not deprecated."
+      }
+    """
+
+  def fileAlreadyExistsError(id: String) =
+    json"""
+      {
+        "@context": "https://bluebrain.github.io/nexus/contexts/error.json",
+        "@type": "ResourceAlreadyExists",
+        "reason": "Resource '$id' already exists in project 'org/proj'."
+      }
+    """
+
+}

From ab7bc755c7ce9f4068b4c89c2dbe75bac971f033 Mon Sep 17 00:00:00 2001
From: Oliver <20188437+olivergrabinski@users.noreply.github.com>
Date: Wed, 8 Nov 2023 09:02:23 +0100
Subject: [PATCH 22/22] Add dox

---
 .../delta/api/assets/files/undeprecate.sh     |  2 ++
 .../delta/api/assets/files/undeprecated.json  | 33 +++++++++++++++++++
 .../main/paradox/docs/delta/api/files-api.md  | 22 ++++++++++++-
 .../docs/releases/v1.9-release-notes.md       |  8 +++++
 4 files changed, 64 insertions(+), 1 deletion(-)
 create mode 100644 docs/src/main/paradox/docs/delta/api/assets/files/undeprecate.sh
 create mode 100644 docs/src/main/paradox/docs/delta/api/assets/files/undeprecated.json

diff --git a/docs/src/main/paradox/docs/delta/api/assets/files/undeprecate.sh b/docs/src/main/paradox/docs/delta/api/assets/files/undeprecate.sh
new file mode 100644
index 0000000000..72d8f30ace
--- /dev/null
+++ b/docs/src/main/paradox/docs/delta/api/assets/files/undeprecate.sh
@@ -0,0 +1,2 @@
+curl -X PUT \
+   "http://localhost:8080/v1/files/myorg/myproject/myfile/undeprecate?rev=4"
\ No newline at end of file
diff --git a/docs/src/main/paradox/docs/delta/api/assets/files/undeprecated.json b/docs/src/main/paradox/docs/delta/api/assets/files/undeprecated.json
new file mode 100644
index 0000000000..a005b28216
--- /dev/null
+++ b/docs/src/main/paradox/docs/delta/api/assets/files/undeprecated.json
@@ -0,0 +1,33 @@
+{
+  "@context": [
+    "https://bluebrain.github.io/nexus/contexts/files.json",
+    "https://bluebrain.github.io/nexus/contexts/metadata.json"
+  ],
+  "@id": "http://localhost:8080/v1/resources/myorg/myproject/_/myfile",
+  "@type": "File",
+  "_bytes": 13896460,
+  "_constrainedBy": "https://bluebrain.github.io/nexus/schemas/files.json",
+  "_createdAt": "2021-05-12T07:30:54.576Z",
+  "_createdBy": "http://localhost:8080/v1/anonymous",
+  "_deprecated": false,
+  "_digest": {
+    "_algorithm": "SHA-256",
+    "_value": "4c9f4292e3c0c5fc23cd60722adb8a1535f1dd7f0cf9203140d61fb889eef3cf"
+  },
+  "_filename": "myfile2.pdf",
+  "_incoming": "http://localhost:8080/v1/files/myorg/myproject/myfile/incoming",
+  "_mediaType": "application/pdf",
+  "_origin": "Client",
+  "_outgoing": "http://localhost:8080/v1/files/myorg/myproject/myfile/outgoing",
+  "_project": "http://localhost:8080/v1/projects/myorg/myproject",
+  "_rev": 5,
+  "_self": "http://localhost:8080/v1/files/myorg/myproject/myfile",
+  "_storage": {
+    "@id": "https://bluebrain.github.io/nexus/vocabulary/diskStorageDefault",
+    "@type": "DiskStorage",
+    "_rev": 1
+  },
+  "_updatedAt": "2021-05-12T08:09:23.658Z",
+  "_updatedBy": "http://localhost:8080/v1/anonymous",
+  "_uuid": "3e86d93a-c196-407d-a13c-cea7168e32e3"
+}
diff --git a/docs/src/main/paradox/docs/delta/api/files-api.md b/docs/src/main/paradox/docs/delta/api/files-api.md
index 3994456871..1d326b3a7b 100644
--- a/docs/src/main/paradox/docs/delta/api/files-api.md
+++ b/docs/src/main/paradox/docs/delta/api/files-api.md
@@ -237,7 +237,7 @@ Locks the file, so no further operations can be performed.
 Deprecating a file is considered to be an update as well. 
 
 ```
-DELETE /v1/files/{org_label}/{project_label}?rev={previous_rev}
+DELETE /v1/files/{org_label}/{project_label}/{file_id}?rev={previous_rev}
 ```
 
 ... where `{previous_rev}` is the last known revision number for the file.
@@ -250,6 +250,26 @@ Request
 Response
 :   @@snip [deprecated.json](assets/files/deprecated.json)
 
+## Undeprecate
+
+Unlocks a previously deprecated file. Further operations can then be performed. The file will again be found when listing/querying.
+
+Undeprecating a file is considered to be an update as well.
+
+```
+PUT /v1/file/{org_label}/{project_label}/{file_id}/undeprecate?rev={previous_rev}
+```
+
+... where `{previous_rev}` is the last known revision number for the resource.
+
+**Example**
+
+Request
+:   @@snip [undeprecate.sh](assets/files/undeprecate.sh)
+
+Response
+:   @@snip [undeprecated.json](assets/files/undeprecated.json)
+
 ## Fetch
 
 When fetching a file, the response format can be chosen through HTTP content negotiation. 
diff --git a/docs/src/main/paradox/docs/releases/v1.9-release-notes.md b/docs/src/main/paradox/docs/releases/v1.9-release-notes.md
index 2424326104..f42d0c7752 100644
--- a/docs/src/main/paradox/docs/releases/v1.9-release-notes.md
+++ b/docs/src/main/paradox/docs/releases/v1.9-release-notes.md
@@ -174,6 +174,14 @@ These should instead be defined in the Delta configuration.
 
 ### Files
 
+#### Undeprecate files
+
+Previously deprecated files can now be undeprecated.
+
+@ref:[More information](../delta/api/files-api.md#undeprecate)
+
+#### Automatic media type configuration
+
 The automatic detection of the media type can now be customized at the Delta level.
 
 NB: The media type provided by the user still prevails over automatic detection.