From 2bd4f5606d6e57ecbd403ae7b176871821b0904c Mon Sep 17 00:00:00 2001 From: Dmytro Zaichenko Date: Wed, 2 Sep 2026 16:27:02 +0300 Subject: [PATCH 1/3] test: verify a bucket migrated to the tenant-rooted layout #1870 Copies a legacy tree into the tenant-rooted layout, checks everything arrived, then boots a tenant-rooted core onto the copy with an empty cache and reads it all back. This is the only instrument that crosses layouts. The other two start empty and each run reads only its own writes, so neither can see a failure that happens only to data written under one layout and read under the other, which is the whole of what P2 is about to do. Read-back compares what each resource returned before the move with what it returns after, rather than asserting a 200: the question is whether migration changed anything, not whether every fixture is readable. The copier is a stand-in for the migrator P2 will build, not that migrator. It exists so the verifier has something to verify, and so the questions the verifier asks are settled before the real one lands. Two of those questions came out of building it. A byte-perfect copy still produces an unreadable bucket: whether an object is compressed is recorded beside it rather than inside it, along with its etag, author and creation time, so a copy that keeps every byte and drops the metadata reads back as a parse error. Inventory and checksums both passed while that was true, which is why the verifier compares metadata too. Co-Authored-By: Claude Opus 5 (1M context) --- .../core/server/layout/BucketCopier.java | 115 ++++++++++++ .../core/server/layout/BucketVerifier.java | 163 ++++++++++++++++ .../core/server/layout/DialInstance.java | 23 ++- .../layout/LayoutBucketVerifierTest.java | 176 ++++++++++++++++++ 4 files changed, 473 insertions(+), 4 deletions(-) create mode 100644 server/src/test/java/com/epam/aidial/core/server/layout/BucketCopier.java create mode 100644 server/src/test/java/com/epam/aidial/core/server/layout/BucketVerifier.java create mode 100644 server/src/test/java/com/epam/aidial/core/server/layout/LayoutBucketVerifierTest.java diff --git a/server/src/test/java/com/epam/aidial/core/server/layout/BucketCopier.java b/server/src/test/java/com/epam/aidial/core/server/layout/BucketCopier.java new file mode 100644 index 000000000..16cb88df1 --- /dev/null +++ b/server/src/test/java/com/epam/aidial/core/server/layout/BucketCopier.java @@ -0,0 +1,115 @@ +package com.epam.aidial.core.server.layout; + +import com.epam.aidial.core.storage.resource.TenantLayoutTransform; +import lombok.SneakyThrows; +import lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.UserDefinedFileAttributeView; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +/** + * Copies a blob tree from the legacy layout into the tenant-rooted one. + * + *

A stand-in for the migrator P2 will build, not the migrator itself: it copies bytes and rewrites paths, + * with none of the throughput, resumability or re-encryption a real one needs. It exists so the verifier has + * something to verify before the real one lands, and so the questions the verifier asks are settled first. + */ +@Slf4j +@UtilityClass +public class BucketCopier { + + /** + * Splits a physical path into the bucket location and what follows, by finding the resource-type folder. + * The two halves are what {@link TenantLayoutTransform} converts, and a path is only made of those two + * plus the resource path within the type. + */ + private record SplitPath(String location, String typeFolder, String rest) { + } + + @SneakyThrows + public static int copy(Path legacyRoot, Path tenantRoot, String tenantId, List typeFolders) { + int copied = 0; + for (Path source : list(legacyRoot)) { + String relative = legacyRoot.relativize(source).toString(); + String transformed = toTenantPath(relative, tenantId, typeFolders); + + Path destination = tenantRoot.resolve(transformed); + Files.createDirectories(destination.getParent()); + Files.copy(source, destination); + copyMetadata(source, destination); + copied++; + } + return copied; + } + + /** + * The path a migrated object lands at. Package-visible so the verifier maps source to destination the same + * way rather than reimplementing the rule. + */ + static String toTenantPath(String legacyPath, String tenantId, List typeFolders) { + SplitPath split = split(legacyPath, typeFolders); + return TenantLayoutTransform.toTenantLocation(split.location(), tenantId) + + TenantLayoutTransform.toTenantTypeFolder(split.typeFolder()) + + "/" + split.rest(); + } + + private static SplitPath split(String legacyPath, List typeFolders) { + String[] segments = legacyPath.split("/"); + for (int i = 0; i < segments.length; i++) { + if (!typeFolders.contains(segments[i])) { + continue; + } + String location = i == 0 ? "" : String.join("/", List.of(segments).subList(0, i)) + "/"; + String rest = String.join("/", List.of(segments).subList(i + 1, segments.length)); + return new SplitPath(location, segments[i], rest); + } + throw new IllegalArgumentException("No resource type folder in: " + legacyPath); + } + + /** + * Carries the blob's metadata across, not just its bytes. + * + *

The filesystem blob store keeps content-encoding, content-type and the user metadata — author, + * created_at, etag — in extended attributes, and a plain byte copy silently drops them. A compressed + * resource then arrives as gzip bytes nobody knows to decompress, and reads back as a parse error rather + * than as anything obviously missing. + */ + @SneakyThrows + private static void copyMetadata(Path source, Path destination) { + UserDefinedFileAttributeView from = Files.getFileAttributeView(source, UserDefinedFileAttributeView.class); + UserDefinedFileAttributeView to = Files.getFileAttributeView(destination, UserDefinedFileAttributeView.class); + if (from == null || to == null) { + return; + } + + for (String name : from.list()) { + ByteBuffer buffer = ByteBuffer.allocate(from.size(name)); + from.read(name, buffer); + buffer.flip(); + try { + to.write(name, buffer); + } catch (IOException systemAttribute) { + // Some attributes belong to the OS rather than the blob store and cannot be set by hand. + log.debug("Could not copy attribute {} of {}", name, source); + } + } + } + + @SneakyThrows + private static List list(Path root) { + List files = new ArrayList<>(); + try (Stream paths = Files.walk(root)) { + paths.filter(Files::isRegularFile).sorted().forEach(files::add); + } catch (IOException e) { + throw new IllegalStateException("Cannot read " + root, e); + } + return files; + } +} diff --git a/server/src/test/java/com/epam/aidial/core/server/layout/BucketVerifier.java b/server/src/test/java/com/epam/aidial/core/server/layout/BucketVerifier.java new file mode 100644 index 000000000..2873ef908 --- /dev/null +++ b/server/src/test/java/com/epam/aidial/core/server/layout/BucketVerifier.java @@ -0,0 +1,163 @@ +package com.epam.aidial.core.server.layout; + +import lombok.SneakyThrows; +import lombok.experimental.UtilityClass; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.UserDefinedFileAttributeView; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +/** + * Checks a bucket that has been copied to the tenant-rooted layout: everything arrived, and nothing extra did. + * + *

Object counts are not the check. A count that is off by one says something is wrong but not what, and a + * count that matches says nothing about which objects matched — so this compares the two trees as + * sets of paths mapped through the same transform the copier used, and names the one file whose absence would + * be silent. + */ +@UtilityClass +public class BucketVerifier { + + /** + * Governs read access to the whole public space. Its absence does not fail loudly: no document means an + * empty rule map, and {@code RuleMatcher} returns true for everything, so a migration that dropped it + * would publish the entire public space to everyone and every object count would still add up. + */ + public static final String RULES_DOCUMENT = "public/rules/rules"; + + public record Result(int sourceObjects, int destinationObjects, List problems) { + public boolean clean() { + return problems.isEmpty(); + } + } + + public static Result verify(Path legacyRoot, Path tenantRoot, String tenantId, List typeFolders) { + Map source = index(legacyRoot); + Map destination = index(tenantRoot); + + List problems = new ArrayList<>(); + Map expected = new LinkedHashMap<>(); + source.keySet().forEach(path -> expected.put(BucketCopier.toTenantPath(path, tenantId, typeFolders), path)); + + for (Map.Entry entry : expected.entrySet()) { + Path arrived = destination.get(entry.getKey()); + if (arrived == null) { + problems.add("missing at the destination: " + entry.getValue() + " → " + entry.getKey()); + continue; + } + if (!sameContent(source.get(entry.getValue()), arrived)) { + problems.add("content differs: " + entry.getValue()); + } + problems.addAll(compareMetadata(entry.getValue(), source.get(entry.getValue()), arrived)); + } + + destination.keySet().stream() + .filter(path -> !expected.containsKey(path)) + .forEach(path -> problems.add("present at the destination with no source: " + path)); + + problems.addAll(verifyRulesDocument(source, destination, tenantId, typeFolders)); + return new Result(source.size(), destination.size(), problems); + } + + /** + * The rules document by name, not as one row in a count. + */ + private static List verifyRulesDocument(Map source, Map destination, + String tenantId, List typeFolders) { + String sourcePath = source.keySet().stream() + .filter(path -> path.endsWith(RULES_DOCUMENT)) + .findFirst() + .orElse(null); + if (sourcePath == null) { + // Nothing was published, so there is no rules document to lose. Said out loud rather than passed + // over: a verifier that silently skips this check on an empty public space is one that would also + // skip it on a migration that dropped the file. + return List.of("note: the source has no " + RULES_DOCUMENT + ", so public access was not verified"); + } + + String expected = BucketCopier.toTenantPath(sourcePath, tenantId, typeFolders); + if (!destination.containsKey(expected)) { + return List.of("THE RULES DOCUMENT DID NOT ARRIVE: " + sourcePath + " → " + expected + + ". Its absence fails open — the whole public space becomes readable by everyone."); + } + if (!sameContent(source.get(sourcePath), destination.get(expected))) { + return List.of("THE RULES DOCUMENT ARRIVED WITH DIFFERENT CONTENT: " + expected); + } + return List.of(); + } + + /** + * Compares the blob's metadata, not only its bytes. + * + *

Bytes alone are not enough to call an object migrated. Whether it is compressed is recorded beside + * it, not inside it, so a copy that keeps every byte and drops that flag reads back as a parse error — + * and an inventory that only checksums content reports such a bucket as perfectly migrated. The etag, + * author and creation time live in the same place. + */ + @SneakyThrows + private static List compareMetadata(String path, Path source, Path destination) { + Map from = metadata(source); + Map to = metadata(destination); + if (from.isEmpty() && to.isEmpty()) { + return List.of(); + } + + List problems = new ArrayList<>(); + for (Map.Entry attribute : from.entrySet()) { + String arrived = to.get(attribute.getKey()); + if (arrived == null) { + problems.add("metadata lost: " + path + " is missing '" + attribute.getKey() + + "' at the destination (was: " + attribute.getValue() + ")"); + } else if (!arrived.equals(attribute.getValue())) { + problems.add("metadata differs: " + path + " '" + attribute.getKey() + "' was " + + attribute.getValue() + ", arrived as " + arrived); + } + } + return problems; + } + + /** + * The blob store's own attributes. System attributes are skipped — they belong to the filesystem rather + * than to the object, and cannot be carried across by a copy. + */ + @SneakyThrows + private static Map metadata(Path file) { + UserDefinedFileAttributeView view = Files.getFileAttributeView(file, UserDefinedFileAttributeView.class); + if (view == null) { + return Map.of(); + } + + Map attributes = new LinkedHashMap<>(); + for (String name : view.list()) { + if (name.startsWith("com.apple.")) { + continue; + } + ByteBuffer buffer = ByteBuffer.allocate(view.size(name)); + view.read(name, buffer); + attributes.put(name, new String(buffer.array(), StandardCharsets.ISO_8859_1)); + } + return attributes; + } + + @SneakyThrows + private static boolean sameContent(Path left, Path right) { + return Files.mismatch(left, right) == -1; + } + + @SneakyThrows + private static Map index(Path root) { + Map files = new LinkedHashMap<>(); + try (Stream paths = Files.walk(root)) { + paths.filter(Files::isRegularFile).sorted() + .forEach(path -> files.put(root.relativize(path).toString(), path)); + } + return files; + } +} diff --git a/server/src/test/java/com/epam/aidial/core/server/layout/DialInstance.java b/server/src/test/java/com/epam/aidial/core/server/layout/DialInstance.java index ea88855bb..1201c9069 100644 --- a/server/src/test/java/com/epam/aidial/core/server/layout/DialInstance.java +++ b/server/src/test/java/com/epam/aidial/core/server/layout/DialInstance.java @@ -62,7 +62,9 @@ public class DialInstance implements AutoCloseable { private final AtomicLong nextId = new AtomicLong(FIRST_ID); + @Getter private final Path dataDir; + private final boolean keepData; private final RedisServer redis; private final AiDial dial; private final CloseableHttpClient client; @@ -71,11 +73,22 @@ public class DialInstance implements AutoCloseable { private final String name; private final int port; - @SneakyThrows public DialInstance(String name, JsonObject layoutSettings, int redisPort) { + this(name, layoutSettings, redisPort, null); + } + + /** + * @param existingData a blob tree to serve rather than start empty from — how the bucket verifier boots a + * core onto migrated data. It is left in place on close. + */ + @SneakyThrows + public DialInstance(String name, JsonObject layoutSettings, int redisPort, Path existingData) { this.name = name; - this.dataDir = FileUtil.resolveRes("layout-diff-" + name); - FileUtil.deleteDir(dataDir); + this.keepData = existingData != null; + this.dataDir = existingData != null ? existingData : FileUtil.resolveRes("layout-diff-" + name); + if (existingData == null) { + FileUtil.deleteDir(dataDir); + } FileUtil.createDir(dataDir.resolve("test")); this.redis = RedisServer.newRedisServer() @@ -281,7 +294,9 @@ private void closeQuietly() { } catch (Exception e) { throw new IllegalStateException("Cannot stop redis for the " + name + " instance", e); } - FileUtil.deleteDir(dataDir); + if (!keepData) { + FileUtil.deleteDir(dataDir); + } } } } diff --git a/server/src/test/java/com/epam/aidial/core/server/layout/LayoutBucketVerifierTest.java b/server/src/test/java/com/epam/aidial/core/server/layout/LayoutBucketVerifierTest.java new file mode 100644 index 000000000..35dd32107 --- /dev/null +++ b/server/src/test/java/com/epam/aidial/core/server/layout/LayoutBucketVerifierTest.java @@ -0,0 +1,176 @@ +package com.epam.aidial.core.server.layout; + +import com.epam.aidial.core.server.FileUtil; +import com.epam.aidial.core.storage.resource.LegacyStorageLayout; +import com.epam.aidial.core.storage.resource.ResourceTypes; +import com.epam.aidial.core.storage.resource.StorageLayouts; +import io.vertx.core.http.HttpMethod; +import io.vertx.core.json.JsonObject; +import lombok.SneakyThrows; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * Migrates a bucket and then serves it: copy a legacy tree into the tenant-rooted layout, check everything + * arrived, and boot a tenant-rooted core onto the copy to read it all back. + * + *

This is the one instrument that crosses layouts. The other two start empty and each run reads only its + * own writes, so neither can see a failure that only happens to data written under one layout and read under + * the other — which is the entire risk P2 carries. + */ +@Tag("layout-diff") +public class LayoutBucketVerifierTest { + + private static final String TENANT = "migrated-tenant"; + + private static final int SOURCE_REDIS_PORT = 16375; + private static final int MIGRATED_REDIS_PORT = 16376; + + /** + * Where the blob store puts objects inside the data directory: the configured bucket, then the prefix. + */ + private static final String BLOB_ROOT = "test/test-2"; + + private static final List TYPE_FOLDERS = + Arrays.stream(ResourceTypes.values()).map(ResourceTypes::group).distinct().toList(); + + @AfterEach + public void restoreLegacyLayout() { + StorageLayouts.useLayout(LegacyStorageLayout.INSTANCE); + } + + @Test + @SneakyThrows + public void testMigratedBucketIsCompleteAndReadable() { + List seed = CorpusRunner.loadCorpus(CorpusRunner.ACCESS_CORPUS); + + Path legacyData = FileUtil.resolveRes("layout-diff-migration-source"); + Path migratedData = FileUtil.resolveRes("layout-diff-migration-destination"); + FileUtil.deleteDir(legacyData); + FileUtil.deleteDir(migratedData); + + Seeded seeded = seedOnLegacyLayout(legacyData, seed); + Map variables = seeded.variables(); + + Path legacyRoot = legacyData.resolve(BLOB_ROOT); + Path migratedRoot = migratedData.resolve(BLOB_ROOT); + int copied = BucketCopier.copy(legacyRoot, migratedRoot, TENANT, TYPE_FOLDERS); + assertTrue(copied > 0, "nothing was copied, so nothing below verifies anything"); + + BucketVerifier.Result result = BucketVerifier.verify(legacyRoot, migratedRoot, TENANT, TYPE_FOLDERS); + if (!result.clean()) { + fail("Migrated bucket did not verify (" + result.sourceObjects() + " source objects, " + + result.destinationObjects() + " destination):\n " + String.join("\n ", result.problems())); + } + + readBackOnTenantRootedLayout(migratedData, seeded); + } + + /** + * Writes a representative bucket on the legacy layout and leaves it on disk. + */ + private record Seeded(Map variables, Map readBack) { + } + + private static Seeded seedOnLegacyLayout(Path dataDir, List seed) throws Exception { + try (DialInstance instance = + new DialInstance("migration-source", new JsonObject().put("tenantRooted", false), + SOURCE_REDIS_PORT, dataDir)) { + CorpusRunner.Run run = CorpusRunner.replay(instance, seed); + // What the resources read back as before anything moved. The question is whether migration + // changes that, not whether every read is a 200 — some fixtures are legitimately not readable, + // and asserting 200 outright would make the suite fail for reasons that have nothing to do with + // the layout. + Map readBack = readAll(instance, run.variables()); + // Writes land in Redis first and are flushed behind the request; copying before that would copy a + // tree that is missing whatever had not been written out yet. + Thread.sleep(5000); + return new Seeded(run.variables(), readBack); + } finally { + StorageLayouts.useLayout(LegacyStorageLayout.INSTANCE); + } + } + + private static final List READ_BACK = List.of( + "conversations/${bucket1}/access/own", + "conversations/${bucket1}/access/shared", + "conversations/${bucket1}/access/writable", + "conversations/${bucket1}/access/publishable", + "applications/${bucket1}/access/app", + "files/${bucket1}/appdata/testapp/data.txt", + "conversations/public/access/published"); + + private static Map readAll(DialInstance instance, Map variables) { + Map results = new LinkedHashMap<>(); + for (String url : READ_BACK) { + RecordedResponse response = instance.send(HttpMethod.GET.name(), "/v1/" + resolve(url, variables), + null, null, Map.of("api-key", "proxyKey1"), null); + results.put(url, response.status() + " " + response.body()); + } + return results; + } + + /** + * Boots a tenant-rooted core onto the migrated tree, with an empty cache, and reads the seeded resources + * back through the API. Inventory says the bytes arrived; only this says they can still be served. + */ + private static void readBackOnTenantRootedLayout(Path migratedData, Seeded seeded) { + Map variables = seeded.variables(); + try (DialInstance instance = new DialInstance("migration-destination", new JsonObject() + .put("tenantRooted", true) + .put("defaultTenant", TENANT), MIGRATED_REDIS_PORT, migratedData)) { + + assertEquals(variables.get("bucket1"), instance.bucket("proxyKey1"), + "the migrated core resolves a different bucket, so nothing below addresses the same data"); + + Map after = readAll(instance, variables); + assertTrue(after.values().stream().anyMatch(result -> result.startsWith("200")), + () -> "nothing at all was readable after migration, so this proves nothing: " + after); + + List changed = READ_BACK.stream() + .filter(url -> !seeded.readBack().get(url).equals(after.get(url))) + .map(url -> " " + url + "\n before migration: " + seeded.readBack().get(url) + + "\n after migration: " + after.get(url)) + .toList(); + if (!changed.isEmpty()) { + fail("Migration changed what these resources read back as:\n" + String.join("\n", changed)); + } + + // The share and the publication have to survive too: they are the state that makes the data + // usable rather than merely present. + RecordedResponse shared = instance.send("POST", "/v1/ops/resource/share/list", null, + "{\"resourceTypes\":[\"CONVERSATION\"],\"with\":\"me\"}", + Map.of("api-key", "proxyKey2"), null); + assertEquals(200, shared.status(), () -> "share listing failed after migration: " + shared.body()); + assertTrue(shared.body().contains("access/shared"), + () -> "the share did not survive migration: " + shared.body()); + + RecordedResponse publicRead = instance.send(HttpMethod.GET.name(), + "/v1/conversations/public/access/published", null, null, + Map.of("api-key", "proxyKey2"), null); + assertEquals(200, publicRead.status(), + () -> "published resource is not readable after migration: " + publicRead.body()); + } finally { + StorageLayouts.useLayout(LegacyStorageLayout.INSTANCE); + } + } + + private static String resolve(String template, Map variables) { + String resolved = template; + for (Map.Entry variable : variables.entrySet()) { + resolved = resolved.replace("${" + variable.getKey() + "}", variable.getValue()); + } + return resolved; + } +} From 0aa05790d00bf02781cbf773e04186ff1d5eb0f3 Mon Sep 17 00:00:00 2001 From: Dmytro Zaichenko Date: Wed, 2 Sep 2026 18:56:14 +0300 Subject: [PATCH 2/3] test: verify an encrypted secret decrypts after migration #1870 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verifier moved bytes but nothing it moved was ciphertext, so the one hazard specific to encrypted resources — an AAD derived from a path that migration changes — was never exercised. The seed now stores a user-authored external service with a client secret, and serving it after migration is a named check like the rules document: before/after equality alone would also pass if both reads failed identically. Co-Authored-By: Claude Fable 5 --- .../layout/LayoutBucketVerifierTest.java | 45 ++++++++++++++----- .../layout-diff/access-corpus/01-seed.json | 44 ++++++++++++++++++ 2 files changed, 77 insertions(+), 12 deletions(-) diff --git a/server/src/test/java/com/epam/aidial/core/server/layout/LayoutBucketVerifierTest.java b/server/src/test/java/com/epam/aidial/core/server/layout/LayoutBucketVerifierTest.java index 35dd32107..398be6201 100644 --- a/server/src/test/java/com/epam/aidial/core/server/layout/LayoutBucketVerifierTest.java +++ b/server/src/test/java/com/epam/aidial/core/server/layout/LayoutBucketVerifierTest.java @@ -102,21 +102,34 @@ private static Seeded seedOnLegacyLayout(Path dataDir, List seed) thro } } - private static final List READ_BACK = List.of( - "conversations/${bucket1}/access/own", - "conversations/${bucket1}/access/shared", - "conversations/${bucket1}/access/writable", - "conversations/${bucket1}/access/publishable", - "applications/${bucket1}/access/app", - "files/${bucket1}/appdata/testapp/data.txt", - "conversations/public/access/published"); + /** + * The external-service entry authenticates as the user who owns it: user-authored services live in the + * caller's own user bucket, which an api key does not have. + */ + private record ReadBack(String url, Map headers) { + static ReadBack byApiKey(String url) { + return new ReadBack(url, Map.of("api-key", "proxyKey1")); + } + } + + private static final String EXTERNAL_SERVICE_URL = "applications/${svcOwnerBucket}/svcapp/external-services/billing"; + + private static final List READ_BACK = List.of( + ReadBack.byApiKey("conversations/${bucket1}/access/own"), + ReadBack.byApiKey("conversations/${bucket1}/access/shared"), + ReadBack.byApiKey("conversations/${bucket1}/access/writable"), + ReadBack.byApiKey("conversations/${bucket1}/access/publishable"), + ReadBack.byApiKey("applications/${bucket1}/access/app"), + ReadBack.byApiKey("files/${bucket1}/appdata/testapp/data.txt"), + ReadBack.byApiKey("conversations/public/access/published"), + new ReadBack(EXTERNAL_SERVICE_URL, Map.of("authorization", "svc-owner"))); private static Map readAll(DialInstance instance, Map variables) { Map results = new LinkedHashMap<>(); - for (String url : READ_BACK) { - RecordedResponse response = instance.send(HttpMethod.GET.name(), "/v1/" + resolve(url, variables), - null, null, Map.of("api-key", "proxyKey1"), null); - results.put(url, response.status() + " " + response.body()); + for (ReadBack readBack : READ_BACK) { + RecordedResponse response = instance.send(HttpMethod.GET.name(), + "/v1/" + resolve(readBack.url(), variables), null, null, readBack.headers(), null); + results.put(readBack.url(), response.status() + " " + response.body()); } return results; } @@ -139,6 +152,7 @@ private static void readBackOnTenantRootedLayout(Path migratedData, Seeded seede () -> "nothing at all was readable after migration, so this proves nothing: " + after); List changed = READ_BACK.stream() + .map(ReadBack::url) .filter(url -> !seeded.readBack().get(url).equals(after.get(url))) .map(url -> " " + url + "\n before migration: " + seeded.readBack().get(url) + "\n after migration: " + after.get(url)) @@ -161,6 +175,13 @@ private static void readBackOnTenantRootedLayout(Path migratedData, Seeded seede Map.of("api-key", "proxyKey2"), null); assertEquals(200, publicRead.status(), () -> "published resource is not readable after migration: " + publicRead.body()); + + // Named check, like the rules document: this resource stores an AAD-encrypted client secret, and + // serving it requires the migrated ciphertext to decrypt. Before/after equality alone would also + // pass if both reads failed identically, which for the one encrypted fixture is not good enough. + assertTrue(after.get(EXTERNAL_SERVICE_URL).startsWith("200"), + () -> "the encrypted external service does not decrypt after migration: " + + after.get(EXTERNAL_SERVICE_URL)); } finally { StorageLayouts.useLayout(LegacyStorageLayout.INSTANCE); } diff --git a/server/src/test/resources/layout-diff/access-corpus/01-seed.json b/server/src/test/resources/layout-diff/access-corpus/01-seed.json index 90d44577f..2bd3093ef 100644 --- a/server/src/test/resources/layout-diff/access-corpus/01-seed.json +++ b/server/src/test/resources/layout-diff/access-corpus/01-seed.json @@ -268,6 +268,50 @@ "assistantModelId": "a", "lastActivityDate": 6 } + }, + { + "name": "svc-owner-bucket", + "method": "GET", + "path": "/v1/bucket", + "headers": { + "authorization": "svc-owner" + }, + "capture": { + "svcOwnerBucket": { + "at": "/bucket" + } + } + }, + { + "name": "create-svc-app", + "method": "PUT", + "path": "/v1/applications/${svcOwnerBucket}/svcapp", + "headers": { + "authorization": "svc-owner" + }, + "body": { + "display_name": "svc app", + "endpoint": "http://localhost:9999/completions", + "description": "carries a user-authored external service with an encrypted client secret" + } + }, + { + "name": "create-external-service", + "method": "PUT", + "path": "/v1/applications/${svcOwnerBucket}/svcapp/external-services/billing", + "headers": { + "authorization": "svc-owner" + }, + "body": { + "display_name": "Billing", + "auth_settings": { + "authentication_type": "OAUTH", + "client_id": "layout-diff-client", + "client_secret": "layout-diff-secret", + "authorization_endpoint": "http://localhost:9999/authorize", + "token_endpoint": "http://localhost:9999/token" + } + } } ] } From f7c5375d628e48b835cdbddda15161132c8e1fa3 Mon Sep 17 00:00:00 2001 From: Dmytro Zaichenko Date: Mon, 7 Sep 2026 14:32:26 +0300 Subject: [PATCH 3/3] test: follow the TenantLayoutTransformer rename #1870 Co-Authored-By: Claude Fable 5 --- .../com/epam/aidial/core/server/layout/BucketCopier.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/src/test/java/com/epam/aidial/core/server/layout/BucketCopier.java b/server/src/test/java/com/epam/aidial/core/server/layout/BucketCopier.java index 16cb88df1..9026089a9 100644 --- a/server/src/test/java/com/epam/aidial/core/server/layout/BucketCopier.java +++ b/server/src/test/java/com/epam/aidial/core/server/layout/BucketCopier.java @@ -1,6 +1,6 @@ package com.epam.aidial.core.server.layout; -import com.epam.aidial.core.storage.resource.TenantLayoutTransform; +import com.epam.aidial.core.storage.resource.TenantLayoutTransformer; import lombok.SneakyThrows; import lombok.experimental.UtilityClass; import lombok.extern.slf4j.Slf4j; @@ -27,7 +27,7 @@ public class BucketCopier { /** * Splits a physical path into the bucket location and what follows, by finding the resource-type folder. - * The two halves are what {@link TenantLayoutTransform} converts, and a path is only made of those two + * The two halves are what {@link TenantLayoutTransformer} converts, and a path is only made of those two * plus the resource path within the type. */ private record SplitPath(String location, String typeFolder, String rest) { @@ -55,8 +55,8 @@ public static int copy(Path legacyRoot, Path tenantRoot, String tenantId, List typeFolders) { SplitPath split = split(legacyPath, typeFolders); - return TenantLayoutTransform.toTenantLocation(split.location(), tenantId) - + TenantLayoutTransform.toTenantTypeFolder(split.typeFolder()) + return TenantLayoutTransformer.toTenantLocation(split.location(), tenantId) + + TenantLayoutTransformer.toTenantTypeFolder(split.typeFolder()) + "/" + split.rest(); }