diff --git a/README.md b/README.md index b08419b9e..7c16d1ccd 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,8 @@ without putting the raw address in the log. | storage.bucket | - | No | Blob storage bucket. | | storage.overrides.* | - | No | Key-value pairs to override storage settings. `*` might be any specific blob storage setting to be overridden. Refer to [examples](#temporary-credentials-1) in the sections below. | | storage.createBucket | false | No | Indicates whether bucket should be created on start-up. | +| storageLayout.tenantRooted | false | No | Places every bucket under a tenant root instead of storing it at the top level. Changing it on a populated deployment re-addresses existing data and requires migration; leave disabled otherwise. | +| storageLayout.defaultTenant | default | No | Tenant that existing buckets are placed under when `storageLayout.tenantRooted` is enabled. | | storage.prefix | - | No | Base prefix for all stored resources. The purpose to use the same bucket for different environments, e.g. dev, prod, pre-prod. Must not contain path separators or any invalid chars. | | storage.maxUploadedFileSize | 536870912 | No | Maximum size in bytes of uploaded file. If a size of uploaded file exceeds the limit the server returns HTTP code 413 | diff --git a/server/src/main/java/com/epam/aidial/core/server/AiDial.java b/server/src/main/java/com/epam/aidial/core/server/AiDial.java index 5ff1b0277..445c8e906 100644 --- a/server/src/main/java/com/epam/aidial/core/server/AiDial.java +++ b/server/src/main/java/com/epam/aidial/core/server/AiDial.java @@ -91,8 +91,12 @@ import com.epam.aidial.core.storage.blobstore.BlobStorage; import com.epam.aidial.core.storage.blobstore.Storage; import com.epam.aidial.core.storage.cache.CacheClientFactory; +import com.epam.aidial.core.storage.resource.LegacyStorageLayout; import com.epam.aidial.core.storage.resource.ResourceDescriptor; import com.epam.aidial.core.storage.resource.ResourceTypes; +import com.epam.aidial.core.storage.resource.StorageLayout; +import com.epam.aidial.core.storage.resource.StorageLayouts; +import com.epam.aidial.core.storage.resource.TenantRootedStorageLayout; import com.epam.aidial.core.storage.service.LockService; import com.epam.aidial.core.storage.service.ResourceService; import com.epam.aidial.core.storage.service.TimerService; @@ -206,6 +210,8 @@ void start() throws Exception { accessTokenValidator = new AccessTokenValidator(settings("identityProviders"), vertx, taskExecutor, client, clientOptions, claimsLogLevel); } + StorageLayouts.useLayout(createStorageLayout(settings("storageLayout"))); + if (storage == null) { Storage storageConfig = Json.decodeValue(settings("storage").toBuffer(), Storage.class); storage = new BlobStorage(storageConfig); @@ -502,6 +508,14 @@ private JsonObject settings(String key) { return settings.getJsonObject(key, new JsonObject()); } + private static StorageLayout createStorageLayout(JsonObject settings) { + if (!settings.getBoolean("tenantRooted", false)) { + return LegacyStorageLayout.INSTANCE; + } + + return new TenantRootedStorageLayout(settings.getString("defaultTenant", "default")); + } + private List getAllowedRedirectUris() { return settings("toolsets") .getJsonObject("security", new JsonObject()) diff --git a/server/src/main/resources/aidial.settings.json b/server/src/main/resources/aidial.settings.json index f65f4bb57..db960d2b3 100644 --- a/server/src/main/resources/aidial.settings.json +++ b/server/src/main/resources/aidial.settings.json @@ -55,6 +55,10 @@ "jclouds.filesystem.basedir": "data" } }, + "storageLayout": { + "tenantRooted": false, + "defaultTenant": "default" + }, "resources": { "maxSize" : 67108864, "maxSizeToCache": 1048576, diff --git a/server/src/test/java/com/epam/aidial/core/server/TenantRootedLayoutApiTest.java b/server/src/test/java/com/epam/aidial/core/server/TenantRootedLayoutApiTest.java new file mode 100644 index 000000000..72c3d4589 --- /dev/null +++ b/server/src/test/java/com/epam/aidial/core/server/TenantRootedLayoutApiTest.java @@ -0,0 +1,86 @@ +package com.epam.aidial.core.server; + +import com.epam.aidial.core.storage.resource.LegacyStorageLayout; +import com.epam.aidial.core.storage.resource.StorageLayouts; +import io.vertx.core.http.HttpMethod; +import io.vertx.core.json.JsonObject; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Drives the resource API with {@code storageLayout.tenantRooted} enabled: the whole stack — descriptor, + * cache and blob store — has to agree on the tenant-rooted paths, which unit tests cannot show. + */ +public class TenantRootedLayoutApiTest extends ResourceBaseTest { + + private static final String TENANT = "test-tenant"; + + @Override + protected JsonObject additionalSettingsOverrides() { + return new JsonObject().put("storageLayout", new JsonObject() + .put("tenantRooted", true) + .put("defaultTenant", TENANT)); + } + + @AfterEach + public void restoreLegacyLayout() { + StorageLayouts.useLayout(LegacyStorageLayout.INSTANCE); + } + + @Test + public void testResourceRoundTrip() { + Response created = resourceRequest(HttpMethod.PUT, "/folder/conversation", CONVERSATION_BODY_1); + assertEquals(200, created.status()); + + Response read = resourceRequest(HttpMethod.GET, "/folder/conversation"); + assertEquals(200, read.status()); + assertEquals(CONVERSATION_BODY_1, read.body()); + } + + @Test + public void testResourceListingAndDeletion() { + assertEquals(200, resourceRequest(HttpMethod.PUT, "/folder/conversation", CONVERSATION_BODY_1).status()); + + Response listing = metadata("/folder/"); + assertEquals(200, listing.status()); + assertTrue(listing.body().contains("conversations/" + bucket + "/folder/conversation"), + () -> "Unexpected listing: " + listing.body()); + + assertEquals(200, resourceRequest(HttpMethod.DELETE, "/folder/conversation").status()); + assertEquals(404, resourceRequest(HttpMethod.GET, "/folder/conversation").status()); + } + + @Test + public void testBlobIsStoredUnderTenantRoot() throws IOException { + assertEquals(200, resourceRequest(HttpMethod.PUT, "/folder/conversation", CONVERSATION_BODY_1).status()); + // the resource is written back to the blob store asynchronously + Response flushed = resourceRequest(HttpMethod.GET, "/folder/conversation"); + assertEquals(200, flushed.status()); + + List storedPaths = findStoredPaths(""); + List tenantRootedPaths = storedPaths.stream() + .filter(path -> path.toString().contains(".org/" + TENANT)) + .toList(); + assertTrue(!tenantRootedPaths.isEmpty(), + () -> "No blob stored under the tenant root, found: " + storedPaths); + assertTrue(tenantRootedPaths.stream().anyMatch(path -> path.toString().contains(".conversations")), + () -> "Conversations are not stored in a reserved type folder: " + tenantRootedPaths); + } + + private List findStoredPaths(String marker) throws IOException { + try (Stream paths = Files.walk(testDir)) { + return paths.filter(Files::isRegularFile) + .filter(path -> path.toString().contains(marker)) + .toList(); + } + } +} diff --git a/storage/src/main/java/com/epam/aidial/core/storage/resource/LegacyStorageLayout.java b/storage/src/main/java/com/epam/aidial/core/storage/resource/LegacyStorageLayout.java new file mode 100644 index 000000000..d124908e3 --- /dev/null +++ b/storage/src/main/java/com/epam/aidial/core/storage/resource/LegacyStorageLayout.java @@ -0,0 +1,22 @@ +package com.epam.aidial.core.storage.resource; + +/** + * The bucket-rooted layout: the location prefix and the resource-type folder are stored verbatim. + */ +public final class LegacyStorageLayout implements StorageLayout { + + public static final LegacyStorageLayout INSTANCE = new LegacyStorageLayout(); + + private LegacyStorageLayout() { + } + + @Override + public String resolveLocationPrefix(String bucketLocation) { + return bucketLocation; + } + + @Override + public String resolveTypeFolder(String group) { + return group; + } +} diff --git a/storage/src/main/java/com/epam/aidial/core/storage/resource/ResourceDescriptor.java b/storage/src/main/java/com/epam/aidial/core/storage/resource/ResourceDescriptor.java index 037be457c..caa6a8c98 100644 --- a/storage/src/main/java/com/epam/aidial/core/storage/resource/ResourceDescriptor.java +++ b/storage/src/main/java/com/epam/aidial/core/storage/resource/ResourceDescriptor.java @@ -23,6 +23,7 @@ public class ResourceDescriptor { public static final String PLATFORM_BUCKET = "platform"; public static final String PLATFORM_LOCATION = PLATFORM_BUCKET + PATH_SEPARATOR; + ResourceType type; /** * Resource's name or empty if the resource is a folder @@ -106,9 +107,7 @@ public String getDecodedUrl() { */ public String getAbsoluteFilePath() { StringBuilder builder = new StringBuilder(); - builder.append(bucketLocation) - .append(type.group()) - .append(PATH_SEPARATOR); + builder.append(getStoragePrefix()); if (!parentFolders.isEmpty()) { builder.append(getParentPath()) @@ -126,6 +125,15 @@ public String getAbsoluteFilePath() { return builder.toString(); } + /** + * Returns the layout-dependent prefix every physical path of this resource starts with: the bucket + * location followed by the resource-type folder. + */ + private String getStoragePrefix() { + StorageLayout layout = StorageLayouts.resolveActive(); + return layout.resolveLocationPrefix(bucketLocation) + layout.resolveTypeFolder(type.group()) + PATH_SEPARATOR; + } + /** * Returns the parent resource if any. */ @@ -210,7 +218,7 @@ public ResourceDescriptor resolveByUrl(String url) { * @param path - to the resource with decrypted bucket */ public ResourceDescriptor resolveByPath(String path) { - String prefix = bucketLocation + type.group() + PATH_SEPARATOR; + String prefix = getStoragePrefix(); if (!isFolder) { throw new IllegalStateException("Resource must be a folder"); } diff --git a/storage/src/main/java/com/epam/aidial/core/storage/resource/StorageLayout.java b/storage/src/main/java/com/epam/aidial/core/storage/resource/StorageLayout.java new file mode 100644 index 000000000..7a25bc276 --- /dev/null +++ b/storage/src/main/java/com/epam/aidial/core/storage/resource/StorageLayout.java @@ -0,0 +1,13 @@ +package com.epam.aidial.core.storage.resource; + +/** + * Supplies the two parts of a physical storage path that differ between storage layouts: the bucket + * location prefix and the resource-type folder. {@link ResourceDescriptor} composes them with the + * resource path, which is layout-independent. + */ +public interface StorageLayout { + + String resolveLocationPrefix(String bucketLocation); + + String resolveTypeFolder(String group); +} diff --git a/storage/src/main/java/com/epam/aidial/core/storage/resource/StorageLayouts.java b/storage/src/main/java/com/epam/aidial/core/storage/resource/StorageLayouts.java new file mode 100644 index 000000000..cb3eb6e22 --- /dev/null +++ b/storage/src/main/java/com/epam/aidial/core/storage/resource/StorageLayouts.java @@ -0,0 +1,22 @@ +package com.epam.aidial.core.storage.resource; + +/** + * Holds the layout every physical path is composed with. Set once during start-up, before any resource + * is read or written; process-wide because {@link ResourceDescriptor} is constructed everywhere and + * carries no configuration of its own. + */ +public final class StorageLayouts { + + private static volatile StorageLayout active = LegacyStorageLayout.INSTANCE; + + private StorageLayouts() { + } + + public static StorageLayout resolveActive() { + return active; + } + + public static void useLayout(StorageLayout layout) { + active = layout; + } +} diff --git a/storage/src/main/java/com/epam/aidial/core/storage/resource/TenantLayoutTransform.java b/storage/src/main/java/com/epam/aidial/core/storage/resource/TenantLayoutTransform.java new file mode 100644 index 000000000..b9a0b41bc --- /dev/null +++ b/storage/src/main/java/com/epam/aidial/core/storage/resource/TenantLayoutTransform.java @@ -0,0 +1,124 @@ +package com.epam.aidial.core.storage.resource; + +import lombok.experimental.UtilityClass; + +import javax.annotation.Nullable; + +/** + * Converts the two halves of a physical storage path between the legacy bucket-rooted layout and the + * tenant-rooted one: the bucket location prefix and the resource-type folder. Path composition itself + * stays in {@link ResourceDescriptor#getAbsoluteFilePath()}. + * + *

The conversion is total and reversible in both directions, so a migrated path can always be mapped + * back to its origin. + * + *

Legacy locations are produced by the server-side bucket builder; the prefixes are repeated here + * because this module cannot depend on it. + */ +@UtilityClass +public class TenantLayoutTransform { + + private static final String LEGACY_USERS_PREFIX = "Users/"; + private static final String LEGACY_KEYS_PREFIX = "Keys/"; + + private static final String ORG_PREFIX = ".org/"; + private static final String USERS_SEGMENT = ".users/"; + private static final String KEYS_SEGMENT = ".keys/"; + + /** + * The platform scope is the root of the tenant-rooted tree, above any tenant, so it has no prefix. + */ + private static final String PLATFORM_LOCATION = ""; + + private static final char TYPE_FOLDER_MARKER = '.'; + + public String toTenantLocation(String legacyLocation, String tenantId) { + if (ResourceDescriptor.PLATFORM_LOCATION.equals(legacyLocation)) { + return PLATFORM_LOCATION; + } + + String tenantRoot = tenantRoot(tenantId); + if (ResourceDescriptor.PUBLIC_LOCATION.equals(legacyLocation)) { + return tenantRoot; + } + + String userId = principalId(legacyLocation, LEGACY_USERS_PREFIX); + if (userId != null) { + return tenantRoot + USERS_SEGMENT + userId; + } + + String project = principalId(legacyLocation, LEGACY_KEYS_PREFIX); + if (project != null) { + return tenantRoot + KEYS_SEGMENT + project; + } + + throw new IllegalArgumentException("Unsupported legacy bucket location: " + legacyLocation); + } + + public String toLegacyLocation(String tenantLocation, String tenantId) { + if (PLATFORM_LOCATION.equals(tenantLocation)) { + return ResourceDescriptor.PLATFORM_LOCATION; + } + + String tenantRoot = tenantRoot(tenantId); + if (!tenantLocation.startsWith(tenantRoot)) { + throw new IllegalArgumentException("Location does not belong to tenant " + tenantId + ": " + tenantLocation); + } + + String scope = tenantLocation.substring(tenantRoot.length()); + if (scope.isEmpty()) { + return ResourceDescriptor.PUBLIC_LOCATION; + } + + String userId = principalId(scope, USERS_SEGMENT); + if (userId != null) { + return LEGACY_USERS_PREFIX + userId; + } + + String project = principalId(scope, KEYS_SEGMENT); + if (project != null) { + return LEGACY_KEYS_PREFIX + project; + } + + throw new IllegalArgumentException("Unsupported tenant bucket location: " + tenantLocation); + } + + public String toTenantTypeFolder(String legacyTypeFolder) { + if (legacyTypeFolder.isEmpty() || legacyTypeFolder.charAt(0) == TYPE_FOLDER_MARKER) { + throw new IllegalArgumentException("Unsupported legacy resource type folder: " + legacyTypeFolder); + } + + return TYPE_FOLDER_MARKER + legacyTypeFolder; + } + + public String toLegacyTypeFolder(String tenantTypeFolder) { + if (tenantTypeFolder.length() < 2 || tenantTypeFolder.charAt(0) != TYPE_FOLDER_MARKER) { + throw new IllegalArgumentException("Unsupported tenant resource type folder: " + tenantTypeFolder); + } + + return tenantTypeFolder.substring(1); + } + + private String tenantRoot(String tenantId) { + if (tenantId.isEmpty()) { + throw new IllegalArgumentException("Tenant id must not be empty"); + } + + return ORG_PREFIX + tenantId + ResourceDescriptor.PATH_SEPARATOR; + } + + /** + * Returns the principal id following the given prefix, trailing separator included, or null when the + * location does not carry that prefix. The id may span several segments: an application's own bucket + * is keyed by the application url. + */ + @Nullable + private String principalId(String location, String prefix) { + if (!location.startsWith(prefix)) { + return null; + } + + String id = location.substring(prefix.length()); + return id.length() > 1 && id.endsWith(ResourceDescriptor.PATH_SEPARATOR) ? id : null; + } +} diff --git a/storage/src/main/java/com/epam/aidial/core/storage/resource/TenantRootedStorageLayout.java b/storage/src/main/java/com/epam/aidial/core/storage/resource/TenantRootedStorageLayout.java new file mode 100644 index 000000000..ffabeffce --- /dev/null +++ b/storage/src/main/java/com/epam/aidial/core/storage/resource/TenantRootedStorageLayout.java @@ -0,0 +1,28 @@ +package com.epam.aidial.core.storage.resource; + +/** + * The tenant-rooted layout: every bucket location is placed under its tenant, and resource-type folders + * are reserved names. Conversion rules live in {@link TenantLayoutTransform}. + */ +public final class TenantRootedStorageLayout implements StorageLayout { + + private final String tenantId; + + public TenantRootedStorageLayout(String tenantId) { + if (tenantId == null || tenantId.isBlank()) { + throw new IllegalArgumentException("Tenant id must not be blank"); + } + + this.tenantId = tenantId; + } + + @Override + public String resolveLocationPrefix(String bucketLocation) { + return TenantLayoutTransform.toTenantLocation(bucketLocation, tenantId); + } + + @Override + public String resolveTypeFolder(String group) { + return TenantLayoutTransform.toTenantTypeFolder(group); + } +} diff --git a/storage/src/test/java/com/epam/aidial/core/storage/resource/LegacyStorageLayoutTest.java b/storage/src/test/java/com/epam/aidial/core/storage/resource/LegacyStorageLayoutTest.java new file mode 100644 index 000000000..1f42c0381 --- /dev/null +++ b/storage/src/test/java/com/epam/aidial/core/storage/resource/LegacyStorageLayoutTest.java @@ -0,0 +1,25 @@ +package com.epam.aidial.core.storage.resource; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class LegacyStorageLayoutTest { + + private final StorageLayout layout = LegacyStorageLayout.INSTANCE; + + @Test + public void testLocationPrefixKeptVerbatim() { + assertEquals("Users/u1/", layout.resolveLocationPrefix("Users/u1/")); + assertEquals("public/", layout.resolveLocationPrefix("public/")); + assertEquals("platform/", layout.resolveLocationPrefix("platform/")); + assertEquals("Keys/proj/", layout.resolveLocationPrefix("Keys/proj/")); + } + + @Test + public void testTypeFolderKeptVerbatim() { + for (ResourceTypes type : ResourceTypes.values()) { + assertEquals(type.group(), layout.resolveTypeFolder(type.group())); + } + } +} diff --git a/storage/src/test/java/com/epam/aidial/core/storage/resource/ResourceDescriptorPathTest.java b/storage/src/test/java/com/epam/aidial/core/storage/resource/ResourceDescriptorPathTest.java new file mode 100644 index 000000000..de4d1a8fa --- /dev/null +++ b/storage/src/test/java/com/epam/aidial/core/storage/resource/ResourceDescriptorPathTest.java @@ -0,0 +1,68 @@ +package com.epam.aidial.core.storage.resource; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Pins the physical paths {@link ResourceDescriptor} composes, so that routing them through a + * {@link StorageLayout} stays behaviour-preserving. + */ +public class ResourceDescriptorPathTest { + + @Test + public void testAbsoluteFilePathOfFile() { + ResourceDescriptor file = new ResourceDescriptor(ResourceTypes.FILE, "notes.txt", + List.of("documents"), "bucket", "Users/u1/", false); + + assertEquals("Users/u1/files/documents/notes.txt", file.getAbsoluteFilePath()); + } + + @Test + public void testAbsoluteFilePathOfFolder() { + ResourceDescriptor folder = new ResourceDescriptor(ResourceTypes.FILE, "documents", + List.of(), "bucket", "Users/u1/", true); + + assertEquals("Users/u1/files/documents/", folder.getAbsoluteFilePath()); + } + + @Test + public void testAbsoluteFilePathOfRootFolder() { + ResourceDescriptor root = new ResourceDescriptor(ResourceTypes.FILE, null, + List.of(), "bucket", "public/", true); + + assertEquals("public/files/", root.getAbsoluteFilePath()); + } + + @Test + public void testAbsoluteFilePathUsesResourceTypeGroup() { + ResourceDescriptor application = new ResourceDescriptor(ResourceTypes.APP_TYPE_SCHEMA, "schema", + List.of(), "bucket", "platform/", false); + + assertEquals("platform/app_type_schemas/schema", application.getAbsoluteFilePath()); + } + + @Test + public void testResolveByPath() { + ResourceDescriptor folder = new ResourceDescriptor(ResourceTypes.CONVERSATION, null, + List.of(), "bucket", "Users/u1/", true); + + ResourceDescriptor resolved = folder.resolveByPath("Users/u1/conversations/chats/chat1"); + + assertEquals("chat1", resolved.getName()); + assertEquals(List.of("chats"), resolved.getParentFolders()); + assertEquals("Users/u1/conversations/chats/chat1", resolved.getAbsoluteFilePath()); + } + + @Test + public void testRelativePathWithinFolder() { + ResourceDescriptor folder = new ResourceDescriptor(ResourceTypes.FILE, "documents", + List.of(), "bucket", "Users/u1/", true); + ResourceDescriptor file = new ResourceDescriptor(ResourceTypes.FILE, "notes.txt", + List.of("documents"), "bucket", "Users/u1/", false); + + assertEquals("notes.txt", folder.getRelativePath(file)); + } +} diff --git a/storage/src/test/java/com/epam/aidial/core/storage/resource/StorageLayoutsTest.java b/storage/src/test/java/com/epam/aidial/core/storage/resource/StorageLayoutsTest.java new file mode 100644 index 000000000..53cad8f11 --- /dev/null +++ b/storage/src/test/java/com/epam/aidial/core/storage/resource/StorageLayoutsTest.java @@ -0,0 +1,42 @@ +package com.epam.aidial.core.storage.resource; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +public class StorageLayoutsTest { + + @AfterEach + public void restoreDefaultLayout() { + StorageLayouts.useLayout(LegacyStorageLayout.INSTANCE); + } + + @Test + public void testLegacyLayoutIsActiveByDefault() { + assertSame(LegacyStorageLayout.INSTANCE, StorageLayouts.resolveActive()); + } + + @Test + public void testActiveLayoutIsReplaceable() { + StorageLayout tenantRooted = new TenantRootedStorageLayout("acme"); + StorageLayouts.useLayout(tenantRooted); + + assertSame(tenantRooted, StorageLayouts.resolveActive()); + } + + @Test + public void testDescriptorPathFollowsActiveLayout() { + ResourceDescriptor file = new ResourceDescriptor(ResourceTypes.FILE, "notes.txt", + List.of("documents"), "bucket", "Users/u1/", false); + + assertEquals("Users/u1/files/documents/notes.txt", file.getAbsoluteFilePath()); + + StorageLayouts.useLayout(new TenantRootedStorageLayout("acme")); + + assertEquals(".org/acme/.users/u1/.files/documents/notes.txt", file.getAbsoluteFilePath()); + } +} diff --git a/storage/src/test/java/com/epam/aidial/core/storage/resource/TenantLayoutTransformTest.java b/storage/src/test/java/com/epam/aidial/core/storage/resource/TenantLayoutTransformTest.java new file mode 100644 index 000000000..34560c63f --- /dev/null +++ b/storage/src/test/java/com/epam/aidial/core/storage/resource/TenantLayoutTransformTest.java @@ -0,0 +1,100 @@ +package com.epam.aidial.core.storage.resource; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class TenantLayoutTransformTest { + + private static final String TENANT = "default-tenant"; + + @Test + public void testPlatformLocation() { + assertEquals("", TenantLayoutTransform.toTenantLocation("platform/", TENANT)); + assertEquals("platform/", TenantLayoutTransform.toLegacyLocation("", TENANT)); + } + + @Test + public void testPublicLocation() { + assertEquals(".org/default-tenant/", TenantLayoutTransform.toTenantLocation("public/", TENANT)); + assertEquals("public/", TenantLayoutTransform.toLegacyLocation(".org/default-tenant/", TENANT)); + } + + @Test + public void testUserLocation() { + assertEquals(".org/default-tenant/.users/u1/", TenantLayoutTransform.toTenantLocation("Users/u1/", TENANT)); + assertEquals("Users/u1/", TenantLayoutTransform.toLegacyLocation(".org/default-tenant/.users/u1/", TENANT)); + } + + @Test + public void testKeyLocation() { + assertEquals(".org/default-tenant/.keys/EPM-RTC-GPT/", TenantLayoutTransform.toTenantLocation("Keys/EPM-RTC-GPT/", TENANT)); + assertEquals("Keys/EPM-RTC-GPT/", TenantLayoutTransform.toLegacyLocation(".org/default-tenant/.keys/EPM-RTC-GPT/", TENANT)); + } + + @Test + public void testMultiSegmentKeyLocation() { + String legacy = "Keys/applications/abc123/my-app/"; + String tenant = ".org/default-tenant/.keys/applications/abc123/my-app/"; + + assertEquals(tenant, TenantLayoutTransform.toTenantLocation(legacy, TENANT)); + assertEquals(legacy, TenantLayoutTransform.toLegacyLocation(tenant, TENANT)); + } + + @Test + public void testLocationRoundTrip() { + for (String legacy : new String[] {"platform/", "public/", "Users/u1/", "Keys/proj/", "Keys/applications/abc/app/"}) { + String tenant = TenantLayoutTransform.toTenantLocation(legacy, TENANT); + assertEquals(legacy, TenantLayoutTransform.toLegacyLocation(tenant, TENANT)); + } + } + + @Test + public void testUnsupportedLegacyLocation() { + assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransform.toTenantLocation("Unknown/u1/", TENANT)); + assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransform.toTenantLocation("", TENANT)); + assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransform.toTenantLocation("Users/", TENANT)); + assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransform.toTenantLocation("Users/u1", TENANT)); + } + + @Test + public void testUnsupportedTenantLocation() { + assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransform.toLegacyLocation(".org/default-tenant/.other/u1/", TENANT)); + assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransform.toLegacyLocation(".org/default-tenant/.users/", TENANT)); + } + + @Test + public void testForeignTenantRejected() { + assertThrows(IllegalArgumentException.class, + () -> TenantLayoutTransform.toLegacyLocation(".org/other-tenant/.users/u1/", TENANT)); + } + + @Test + public void testEmptyTenantRejected() { + assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransform.toTenantLocation("public/", "")); + assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransform.toLegacyLocation(".org//", "")); + } + + @Test + public void testTypeFolder() { + assertEquals(".files", TenantLayoutTransform.toTenantTypeFolder("files")); + assertEquals("files", TenantLayoutTransform.toLegacyTypeFolder(".files")); + } + + @Test + public void testTypeFolderRoundTripsForEveryResourceType() { + for (ResourceTypes type : ResourceTypes.values()) { + String tenantFolder = TenantLayoutTransform.toTenantTypeFolder(type.group()); + assertEquals(type.group(), TenantLayoutTransform.toLegacyTypeFolder(tenantFolder)); + } + } + + @Test + public void testUnsupportedTypeFolder() { + assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransform.toTenantTypeFolder("")); + assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransform.toTenantTypeFolder(".files")); + assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransform.toLegacyTypeFolder("files")); + assertThrows(IllegalArgumentException.class, () -> TenantLayoutTransform.toLegacyTypeFolder(".")); + } +} diff --git a/storage/src/test/java/com/epam/aidial/core/storage/resource/TenantRootedStorageLayoutTest.java b/storage/src/test/java/com/epam/aidial/core/storage/resource/TenantRootedStorageLayoutTest.java new file mode 100644 index 000000000..bc87cab9a --- /dev/null +++ b/storage/src/test/java/com/epam/aidial/core/storage/resource/TenantRootedStorageLayoutTest.java @@ -0,0 +1,36 @@ +package com.epam.aidial.core.storage.resource; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class TenantRootedStorageLayoutTest { + + private final StorageLayout layout = new TenantRootedStorageLayout("acme"); + + @Test + public void testLocationPrefixIsTenantRooted() { + assertEquals(".org/acme/.users/u1/", layout.resolveLocationPrefix("Users/u1/")); + assertEquals(".org/acme/.keys/proj/", layout.resolveLocationPrefix("Keys/proj/")); + assertEquals(".org/acme/", layout.resolveLocationPrefix("public/")); + assertEquals("", layout.resolveLocationPrefix("platform/")); + } + + @Test + public void testTypeFolderIsReserved() { + assertEquals(".files", layout.resolveTypeFolder("files")); + assertEquals(".conversations", layout.resolveTypeFolder("conversations")); + } + + @Test + public void testUnsupportedLocationRejected() { + assertThrows(IllegalArgumentException.class, () -> layout.resolveLocationPrefix("Unknown/u1/")); + } + + @Test + public void testBlankTenantRejected() { + assertThrows(IllegalArgumentException.class, () -> new TenantRootedStorageLayout(null)); + assertThrows(IllegalArgumentException.class, () -> new TenantRootedStorageLayout(" ")); + } +}