diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/CreateKeyspaceCommand.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/CreateKeyspaceCommand.java index 5e75e1f0b1..1c2da90054 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/CreateKeyspaceCommand.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/CreateKeyspaceCommand.java @@ -39,7 +39,7 @@ public record Options(@Nullable @Valid Replication replication) {} + "For NetworkTopologyStrategy, use {\"class\": \"NetworkTopologyStrategy\", \"datacenter_name\": N, ...}.") public record Replication( @NotNull - @Pattern(regexp = "SimpleStrategy|NetworkTopologyStrategy") + @Pattern(regexp = "(SimpleStrategy|NetworkTopologyStrategy)") @JsonProperty("class") @Schema( description = @@ -53,7 +53,7 @@ public record Replication( + "For NetworkTopologyStrategy, use datacenter names as keys with replication factor as values " + "(e.g. 'dc1': 3, 'dc2': 2).", type = SchemaType.OBJECT) - Map strategyOptions) {} + Map strategyOptions) {} /** {@inheritDoc} */ @Override diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/CreateNamespaceCommand.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/CreateNamespaceCommand.java index c506421145..bb14bc742c 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/CreateNamespaceCommand.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/CreateNamespaceCommand.java @@ -53,7 +53,7 @@ public record Options(@Nullable @Valid Replication replication) {} deprecated = true) public record Replication( @NotNull() - @Pattern(regexp = "SimpleStrategy|NetworkTopologyStrategy") + @Pattern(regexp = "(SimpleStrategy|NetworkTopologyStrategy)") @JsonProperty("class") @Schema( description = @@ -67,7 +67,7 @@ public record Replication( + "For NetworkTopologyStrategy, use datacenter names as keys with replication factor as values " + "(e.g. 'dc1': 3, 'dc2': 2).", type = SchemaType.OBJECT) - Map strategyOptions) {} + Map strategyOptions) {} /** {@inheritDoc} */ @Override diff --git a/src/main/java/io/stargate/sgv2/jsonapi/exception/SchemaException.java b/src/main/java/io/stargate/sgv2/jsonapi/exception/SchemaException.java index be262773fb..5854200654 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/exception/SchemaException.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/exception/SchemaException.java @@ -45,6 +45,7 @@ public enum Code implements ErrorCode { INVALID_CREATE_COLLECTION_OPTIONS, INVALID_FORMAT_FOR_INDEX_CREATION_COLUMN, INVALID_INDEXING_DEFINITION, + INVALID_REPLICATION_DATA_CENTER_NAME, INVALID_USAGE_OF_VECTORIZE, // legacy: converted from ErrorCodeV1 INVALID_USER_DEFINED_TYPE_NAME, LEXICAL_FEATURE_NOT_ENABLED, diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/keyspaces/CreateKeyspaceOperation.java b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/keyspaces/CreateKeyspaceOperation.java index 0df3177abd..5b27886739 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/keyspaces/CreateKeyspaceOperation.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/keyspaces/CreateKeyspaceOperation.java @@ -1,38 +1,69 @@ package io.stargate.sgv2.jsonapi.service.operation.keyspaces; +import com.datastax.oss.driver.api.core.CqlIdentifier; import com.datastax.oss.driver.api.core.cql.SimpleStatement; +import com.datastax.oss.driver.api.querybuilder.SchemaBuilder; +import com.datastax.oss.driver.api.querybuilder.schema.CreateKeyspace; +import com.datastax.oss.driver.api.querybuilder.schema.CreateKeyspaceStart; import io.smallrye.mutiny.Uni; import io.stargate.sgv2.jsonapi.api.model.command.CommandResult; import io.stargate.sgv2.jsonapi.api.request.RequestContext; import io.stargate.sgv2.jsonapi.service.cqldriver.executor.QueryExecutor; import io.stargate.sgv2.jsonapi.service.operation.Operation; import io.stargate.sgv2.jsonapi.service.operation.collections.SchemaChangeResult; +import java.util.Map; import java.util.function.Supplier; /** - * Operation that creates a new Cassandra keyspace that serves as a namespace for the Data API. + * Operation that creates a Cassandra keyspace for the Data API. * - * @param name Name of the keyspace to create. - * @param replicationMap A replication json, see - * https://docs.datastax.com/en/cql-oss/3.3/cql/cql_reference/cqlCreateKeyspace.html#Table2.Replicationstrategyclassandfactorsettings. + *

The keyspace name is already a CQL identifier when it reaches this operation. Replication map + * keys are still plain strings because that is what the driver accepts, so the resolver validates + * datacenter names before creating this operation. */ -public record CreateKeyspaceOperation(String name, String replicationMap) implements Operation { +public record CreateKeyspaceOperation( + CqlIdentifier name, String strategy, Map strategyOptions) + implements Operation { - // simple pattern for the cql - private static final String CREATE_KEYSPACE_CQL = - "CREATE KEYSPACE IF NOT EXISTS \"%s\" WITH REPLICATION = %s;"; + private static final String NETWORK_TOPOLOGY_STRATEGY = "NetworkTopologyStrategy"; + private static final int DEFAULT_REPLICATION_FACTOR = 1; /** {@inheritDoc} */ @Override public Uni> execute( RequestContext dataApiRequestInfo, QueryExecutor queryExecutor) { - SimpleStatement createKeyspace = - SimpleStatement.newInstance(String.format(CREATE_KEYSPACE_CQL, name, replicationMap)); - // execute + SimpleStatement createKeyspace = buildStatement(); return queryExecutor .executeCreateSchemaChange(dataApiRequestInfo, createKeyspace) - - // if we have a result always respond positively .map(any -> new SchemaChangeResult(any.wasApplied())); } + + private SimpleStatement buildStatement() { + CreateKeyspaceStart start = SchemaBuilder.createKeyspace(name).ifNotExists(); + Map safeStrategyOptions = strategyOptionsOrEmpty(); + + CreateKeyspace withReplication; + if (NETWORK_TOPOLOGY_STRATEGY.equals(strategy)) { + withReplication = start.withNetworkTopologyStrategy(safeStrategyOptions); + } else { + int replicationFactor = + safeStrategyOptions.getOrDefault("replication_factor", DEFAULT_REPLICATION_FACTOR); + withReplication = start.withSimpleStrategy(replicationFactor); + } + return withReplication.build(); + } + + private Map strategyOptionsOrEmpty() { + if (strategyOptions == null) { + return Map.of(); + } + strategyOptions.forEach( + (key, value) -> { + if (value == null) { + throw new IllegalArgumentException( + "Replication strategy option value must not be null for key '%s'".formatted(key)); + } + }); + return strategyOptions; + } } diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/keyspaces/DropKeyspaceOperation.java b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/keyspaces/DropKeyspaceOperation.java index fb707e44ca..659d1bd1c7 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/keyspaces/DropKeyspaceOperation.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/keyspaces/DropKeyspaceOperation.java @@ -1,6 +1,8 @@ package io.stargate.sgv2.jsonapi.service.operation.keyspaces; +import com.datastax.oss.driver.api.core.CqlIdentifier; import com.datastax.oss.driver.api.core.cql.SimpleStatement; +import com.datastax.oss.driver.api.querybuilder.SchemaBuilder; import io.smallrye.mutiny.Uni; import io.stargate.sgv2.jsonapi.api.model.command.CommandResult; import io.stargate.sgv2.jsonapi.api.request.RequestContext; @@ -9,27 +11,18 @@ import io.stargate.sgv2.jsonapi.service.operation.collections.SchemaChangeResult; import java.util.function.Supplier; -/** - * Operation that drops a Cassandra keyspace if it exists. - * - * @param name Name of the keyspace to drop. - */ -public record DropKeyspaceOperation(String name) implements Operation { - - // simple pattern for the cql - private static final String DROP_KEYSPACE_CQL = "DROP KEYSPACE IF EXISTS \"%s\";"; +public record DropKeyspaceOperation(CqlIdentifier name) implements Operation { /** {@inheritDoc} */ @Override public Uni> execute( RequestContext dataApiRequestInfo, QueryExecutor queryExecutor) { - SimpleStatement deleteStatement = - SimpleStatement.newInstance(DROP_KEYSPACE_CQL.formatted(name)); - // execute return queryExecutor - .executeDropSchemaChange(dataApiRequestInfo, deleteStatement) - - // if we have a result always respond positively + .executeDropSchemaChange(dataApiRequestInfo, buildStatement()) .map(any -> new SchemaChangeResult(any.wasApplied())); } + + private SimpleStatement buildStatement() { + return SchemaBuilder.dropKeyspace(name).ifExists().build(); + } } diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/CreateKeyspaceCommandResolver.java b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/CreateKeyspaceCommandResolver.java index ca013050e9..05bdc01c1b 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/CreateKeyspaceCommandResolver.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/CreateKeyspaceCommandResolver.java @@ -1,21 +1,19 @@ package io.stargate.sgv2.jsonapi.service.resolver; +import static io.stargate.sgv2.jsonapi.util.ApiOptionUtils.getOrDefault; + import io.stargate.sgv2.jsonapi.api.model.command.CommandContext; import io.stargate.sgv2.jsonapi.api.model.command.impl.CreateKeyspaceCommand; import io.stargate.sgv2.jsonapi.service.operation.Operation; import io.stargate.sgv2.jsonapi.service.operation.keyspaces.CreateKeyspaceOperation; import io.stargate.sgv2.jsonapi.service.schema.DatabaseSchemaObject; -import io.stargate.sgv2.jsonapi.service.schema.naming.NamingRules; import jakarta.enterprise.context.ApplicationScoped; import java.util.Map; -/** - * Command resolver for {@link CreateKeyspaceCommand}. Responsible for creating the replication map. - * Resolve a {@link CreateKeyspaceCommand} to a {@link CreateKeyspaceOperation} - */ +/** Command resolver for {@link CreateKeyspaceCommand}. */ @ApplicationScoped public class CreateKeyspaceCommandResolver - extends CreateNamespaceKeyspaceCommandResolver { + extends KeyspaceDDLCommandResolver { @Override public Class getCommandClass() { @@ -27,18 +25,16 @@ public Class getCommandClass() { public Operation resolveDatabaseCommand( CommandContext ctx, CreateKeyspaceCommand command) { - var keyspaceName = NamingRules.KEYSPACE.checkRule(command.name()); + var keyspaceName = keyspaceIdentifierForCreate(command.name()); + var replication = + getOrDefault(command.options(), CreateKeyspaceCommand.Options::replication, null); - String strategy = - (command.options() != null && command.options().replication() != null) - ? command.options().replication().strategy() - : null; + String strategy = getOrDefault(replication, CreateKeyspaceCommand.Replication::strategy, null); Map strategyOptions = - (command.options() != null && command.options().replication() != null) - ? command.options().replication().strategyOptions() - : null; - String replicationMap = getReplicationMap(strategy, strategyOptions); - return new CreateKeyspaceOperation(keyspaceName, replicationMap); + getOrDefault(replication, CreateKeyspaceCommand.Replication::strategyOptions, null); + + validateStrategyOptions(strategy, strategyOptions); + return new CreateKeyspaceOperation(keyspaceName, strategy, strategyOptions); } } diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/CreateNamespaceCommandResolver.java b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/CreateNamespaceCommandResolver.java index cda5c7c16d..726f31e89f 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/CreateNamespaceCommandResolver.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/CreateNamespaceCommandResolver.java @@ -1,21 +1,19 @@ package io.stargate.sgv2.jsonapi.service.resolver; +import static io.stargate.sgv2.jsonapi.util.ApiOptionUtils.getOrDefault; + import io.stargate.sgv2.jsonapi.api.model.command.CommandContext; import io.stargate.sgv2.jsonapi.api.model.command.impl.CreateNamespaceCommand; import io.stargate.sgv2.jsonapi.service.operation.Operation; import io.stargate.sgv2.jsonapi.service.operation.keyspaces.CreateKeyspaceOperation; import io.stargate.sgv2.jsonapi.service.schema.DatabaseSchemaObject; -import io.stargate.sgv2.jsonapi.service.schema.naming.NamingRules; import jakarta.enterprise.context.ApplicationScoped; import java.util.Map; -/** - * Command resolver for {@link CreateNamespaceCommand}. Responsible for creating the replication - * map. Resolve a {@link CreateNamespaceCommand} to a {@link CreateKeyspaceOperation} - */ +/** Command resolver for {@link CreateNamespaceCommand}. */ @ApplicationScoped public class CreateNamespaceCommandResolver - extends CreateNamespaceKeyspaceCommandResolver { + extends KeyspaceDDLCommandResolver { @Override public Class getCommandClass() { @@ -27,19 +25,16 @@ public Class getCommandClass() { public Operation resolveDatabaseCommand( CommandContext ctx, CreateNamespaceCommand command) { - var keyspaceName = NamingRules.KEYSPACE.checkRule(command.name()); + var keyspaceName = keyspaceIdentifierForCreate(command.name()); + var replication = + getOrDefault(command.options(), CreateNamespaceCommand.Options::replication, null); - String strategy = - (command.options() != null && command.options().replication() != null) - ? command.options().replication().strategy() - : null; + String strategy = getOrDefault(replication, CreateNamespaceCommand.Replication::strategy, null); Map strategyOptions = - (command.options() != null && command.options().replication() != null) - ? command.options().replication().strategyOptions() - : null; + getOrDefault(replication, CreateNamespaceCommand.Replication::strategyOptions, null); - String replicationMap = getReplicationMap(strategy, strategyOptions); - return new CreateKeyspaceOperation(keyspaceName, replicationMap); + validateStrategyOptions(strategy, strategyOptions); + return new CreateKeyspaceOperation(keyspaceName, strategy, strategyOptions); } } diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/CreateNamespaceKeyspaceCommandResolver.java b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/CreateNamespaceKeyspaceCommandResolver.java deleted file mode 100644 index ae5880d812..0000000000 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/CreateNamespaceKeyspaceCommandResolver.java +++ /dev/null @@ -1,44 +0,0 @@ -package io.stargate.sgv2.jsonapi.service.resolver; - -import io.stargate.sgv2.jsonapi.api.model.command.Command; -import java.util.Map; - -public abstract class CreateNamespaceKeyspaceCommandResolver - implements CommandResolver { - - // default if omitted - private static final String DEFAULT_REPLICATION_MAP = - "{'class': 'SimpleStrategy', 'replication_factor': 1}"; - - // resolve the replication map - String getReplicationMap(String strategy, Map strategyOptions) { - if (strategy == null && strategyOptions == null) { - return DEFAULT_REPLICATION_MAP; - } - if ("NetworkTopologyStrategy".equals(strategy)) { - return networkTopologyStrategyMap(strategyOptions); - } else { - return simpleStrategyMap(strategyOptions); - } - } - - private static String networkTopologyStrategyMap(Map strategyOptions) { - StringBuilder map = new StringBuilder("{'class': 'NetworkTopologyStrategy'"); - if (null != strategyOptions) { - for (Map.Entry dcEntry : strategyOptions.entrySet()) { - map.append(", '%s': %d".formatted(dcEntry.getKey(), dcEntry.getValue())); - } - } - map.append("}"); - return map.toString(); - } - - private static String simpleStrategyMap(Map strategyOptions) { - if (null == strategyOptions || strategyOptions.isEmpty()) { - return DEFAULT_REPLICATION_MAP; - } - - Integer replicationFactor = strategyOptions.getOrDefault("replication_factor", 1); - return "{'class': 'SimpleStrategy', 'replication_factor': " + replicationFactor + "}"; - } -} diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/DropKeyspaceCommandResolver.java b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/DropKeyspaceCommandResolver.java index b6943f5819..25649900c2 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/DropKeyspaceCommandResolver.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/DropKeyspaceCommandResolver.java @@ -9,7 +9,7 @@ /** Command resolver for {@link DropKeyspaceCommand}. */ @ApplicationScoped -public class DropKeyspaceCommandResolver implements CommandResolver { +public class DropKeyspaceCommandResolver extends KeyspaceDDLCommandResolver { /** {@inheritDoc} */ @Override @@ -21,6 +21,6 @@ public Class getCommandClass() { @Override public Operation resolveDatabaseCommand( CommandContext ctx, DropKeyspaceCommand command) { - return new DropKeyspaceOperation(command.name()); + return new DropKeyspaceOperation(keyspaceIdentifierForDrop(command.name())); } } diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/DropNamespaceCommandResolver.java b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/DropNamespaceCommandResolver.java index ce0c886d2f..7a0b227c0b 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/DropNamespaceCommandResolver.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/DropNamespaceCommandResolver.java @@ -7,12 +7,9 @@ import io.stargate.sgv2.jsonapi.service.schema.DatabaseSchemaObject; import jakarta.enterprise.context.ApplicationScoped; -/** - * Command resolver for {@link DropNamespaceCommand}. Resolve a {@link DropNamespaceCommand} to a - * {@link DropKeyspaceOperation} - */ +/** Command resolver for {@link DropNamespaceCommand}. */ @ApplicationScoped -public class DropNamespaceCommandResolver implements CommandResolver { +public class DropNamespaceCommandResolver extends KeyspaceDDLCommandResolver { /** {@inheritDoc} */ @Override @@ -24,6 +21,6 @@ public Class getCommandClass() { @Override public Operation resolveDatabaseCommand( CommandContext ctx, DropNamespaceCommand command) { - return new DropKeyspaceOperation(command.name()); + return new DropKeyspaceOperation(keyspaceIdentifierForDrop(command.name())); } } diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/KeyspaceDDLCommandResolver.java b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/KeyspaceDDLCommandResolver.java new file mode 100644 index 0000000000..93e8d06bbc --- /dev/null +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/KeyspaceDDLCommandResolver.java @@ -0,0 +1,52 @@ +package io.stargate.sgv2.jsonapi.service.resolver; + +import static io.stargate.sgv2.jsonapi.util.CqlIdentifierUtil.cqlIdentifierFromUserInput; + +import com.datastax.oss.driver.api.core.CqlIdentifier; +import io.stargate.sgv2.jsonapi.api.model.command.Command; +import io.stargate.sgv2.jsonapi.exception.ErrorTemplate; +import io.stargate.sgv2.jsonapi.exception.SchemaException; +import io.stargate.sgv2.jsonapi.service.schema.naming.NamingRules; +import java.util.Map; +import java.util.regex.Pattern; + +public abstract class KeyspaceDDLCommandResolver implements CommandResolver { + + private static final String NETWORK_TOPOLOGY_STRATEGY = "NetworkTopologyStrategy"; + + // NetworkTopologyStrategy uses replication map option keys as data center names. Cassandra + // validates those keys semantically against known data centers; they are not CQL identifiers. + // The Java driver query builder renders replication map keys as single-quoted CQL string + // literals, appending the raw key without escaping it. Because the CQL delimiter here is the + // ASCII single quote, reject only that character. Leave non-delimiters such as double quotes, + // backticks, and curly quotes to Cassandra's own data-center validation. + private static final Pattern VALID_DATA_CENTER_NAME = Pattern.compile("^[^']+$"); + + protected CqlIdentifier keyspaceIdentifierForCreate(String name) { + return cqlIdentifierFromUserInput(NamingRules.KEYSPACE.checkRule(name)); + } + + protected CqlIdentifier keyspaceIdentifierForDrop(String name) { + return cqlIdentifierFromUserInput(NamingRules.KEYSPACE.sanitizeInput(name)); + } + + protected void validateStrategyOptions(String strategy, Map strategyOptions) { + if (!isNetworkTopologyStrategy(strategy) || strategyOptions == null) { + return; + } + for (String dcName : strategyOptions.keySet()) { + checkDataCenterName(dcName); + } + } + + private boolean isNetworkTopologyStrategy(String strategy) { + return NETWORK_TOPOLOGY_STRATEGY.equals(strategy); + } + + private void checkDataCenterName(String name) { + if (name == null || !VALID_DATA_CENTER_NAME.matcher(name).matches()) { + throw SchemaException.Code.INVALID_REPLICATION_DATA_CENTER_NAME.get( + Map.of("invalidDataCenterName", ErrorTemplate.replaceIfNull(name))); + } + } +} diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/schema/naming/SchemaObjectNamingRule.java b/src/main/java/io/stargate/sgv2/jsonapi/service/schema/naming/SchemaObjectNamingRule.java index 0a4d9cea3d..a7362babae 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/schema/naming/SchemaObjectNamingRule.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/schema/naming/SchemaObjectNamingRule.java @@ -45,10 +45,7 @@ public SchemaObjectType schemaType() { * @return true if the name is valid, false otherwise */ public boolean apply(String name) { - return name != null - && !name.isEmpty() - && name.length() <= getMaxLength() - && PATTERN_WORD_CHARS.matcher(name).matches(); + return hasAllowedCharacters(name) && name.length() <= getMaxLength(); } /** @@ -58,6 +55,25 @@ public int getMaxLength() { return MAX_NAME_LENGTH; } + /** + * Validates user input without applying the Data API length limit. + * + *

This is useful for schema objects that may have been created outside the Data API, where the + * backing database allows longer names but the input still needs to be safe to pass through the + * API. + * + * @param name The name to validate. + * @return The validated name. + * @throws SchemaException with {@link SchemaException.Code#UNSUPPORTED_SCHEMA_NAME} if the name + * is null, empty, or contains unsupported characters. + */ + public String sanitizeInput(String name) { + if (!hasAllowedCharacters(name)) { + throw unsupportedName(name); + } + return name; + } + /** * Validate the name against the naming rule, and throw a {@link SchemaException} if the name is * invalid. @@ -70,15 +86,23 @@ public int getMaxLength() { public String checkRule(String name) { if (!apply(name)) { - throw SchemaException.Code.UNSUPPORTED_SCHEMA_NAME.get( - Map.of( - "schemaType", - schemaType().apiName(), - "maxNameLength", - String.valueOf(getMaxLength()), - "unsupportedSchemaName", - ErrorTemplate.replaceIfNull(name))); + throw unsupportedName(name); } return name; } + + private boolean hasAllowedCharacters(String name) { + return name != null && PATTERN_WORD_CHARS.matcher(name).matches(); + } + + private SchemaException unsupportedName(String name) { + return SchemaException.Code.UNSUPPORTED_SCHEMA_NAME.get( + Map.of( + "schemaType", + schemaType().apiName(), + "maxNameLength", + String.valueOf(getMaxLength()), + "unsupportedSchemaName", + ErrorTemplate.replaceIfNull(name))); + } } diff --git a/src/main/resources/errors.yaml b/src/main/resources/errors.yaml index 6d267f0457..15f149e805 100644 --- a/src/main/resources/errors.yaml +++ b/src/main/resources/errors.yaml @@ -1624,6 +1624,17 @@ request-errors: Resend the command using a supported value type. + - scope: SCHEMA + code: INVALID_REPLICATION_DATA_CENTER_NAME + title: The used data center name in the replication options is not valid + body: |- + The command included a data center name in the NetworkTopologyStrategy replication options that is not valid. + + Valid data center names must not be empty or contain ASCII single quote characters. + The command used the invalid data center name: '${invalidDataCenterName}'. + + Resend the command using a valid data center name. + - scope: SCHEMA code: UNSUPPORTED_SCHEMA_NAME title: The used schema name is not supported diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/model/command/impl/CreateKeyspaceCommandTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/model/command/impl/CreateKeyspaceCommandTest.java index c5f3333aeb..6df6bfec05 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/model/command/impl/CreateKeyspaceCommandTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/model/command/impl/CreateKeyspaceCommandTest.java @@ -65,6 +65,32 @@ public void strategyNull() throws Exception { .contains("must not be null"); } + @Test + public void strategyOptionValueNull() throws Exception { + String json = + """ + { + "createKeyspace": { + "name": "red_star_belgrade", + "options": { + "replication": { + "class": "NetworkTopologyStrategy", + "dc1": null + } + } + } + } + """; + + CreateKeyspaceCommand command = objectMapper.readValue(json, CreateKeyspaceCommand.class); + Set> result = validator.validate(command); + + assertThat(result) + .isNotEmpty() + .extracting(ConstraintViolation::getMessage) + .contains("must not be null"); + } + @Test public void strategyWrong() throws Exception { String json = @@ -87,7 +113,32 @@ public void strategyWrong() throws Exception { assertThat(result) .isNotEmpty() .extracting(ConstraintViolation::getMessage) - .contains("must match \"SimpleStrategy|NetworkTopologyStrategy\""); + .contains("must match \"(SimpleStrategy|NetworkTopologyStrategy)\""); + } + + @Test + public void strategyPatternIsCaseSensitive() throws Exception { + String json = + """ + { + "createKeyspace": { + "name": "red_star_belgrade", + "options": { + "replication": { + "class": "networktopologystrategy" + } + } + } + } + """; + + CreateKeyspaceCommand command = objectMapper.readValue(json, CreateKeyspaceCommand.class); + Set> result = validator.validate(command); + + assertThat(result) + .isNotEmpty() + .extracting(ConstraintViolation::getMessage) + .contains("must match \"(SimpleStrategy|NetworkTopologyStrategy)\""); } } @@ -135,6 +186,32 @@ public void strategyNull() throws Exception { .contains("must not be null"); } + @Test + public void strategyOptionValueNull() throws Exception { + String json = + """ + { + "createNamespace": { + "name": "red_star_belgrade", + "options": { + "replication": { + "class": "NetworkTopologyStrategy", + "dc1": null + } + } + } + } + """; + + CreateNamespaceCommand command = objectMapper.readValue(json, CreateNamespaceCommand.class); + Set> result = validator.validate(command); + + assertThat(result) + .isNotEmpty() + .extracting(ConstraintViolation::getMessage) + .contains("must not be null"); + } + @Test public void strategyWrong() throws Exception { String json = @@ -157,7 +234,32 @@ public void strategyWrong() throws Exception { assertThat(result) .isNotEmpty() .extracting(ConstraintViolation::getMessage) - .contains("must match \"SimpleStrategy|NetworkTopologyStrategy\""); + .contains("must match \"(SimpleStrategy|NetworkTopologyStrategy)\""); + } + + @Test + public void strategyPatternIsCaseSensitive() throws Exception { + String json = + """ + { + "createNamespace": { + "name": "red_star_belgrade", + "options": { + "replication": { + "class": "networktopologystrategy" + } + } + } + } + """; + + CreateNamespaceCommand command = objectMapper.readValue(json, CreateNamespaceCommand.class); + Set> result = validator.validate(command); + + assertThat(result) + .isNotEmpty() + .extracting(ConstraintViolation::getMessage) + .contains("must match \"(SimpleStrategy|NetworkTopologyStrategy)\""); } } } diff --git a/src/test/java/io/stargate/sgv2/jsonapi/service/operation/keyspaces/CreateKeyspaceOperationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/service/operation/keyspaces/CreateKeyspaceOperationTest.java new file mode 100644 index 0000000000..b55c180ff0 --- /dev/null +++ b/src/test/java/io/stargate/sgv2/jsonapi/service/operation/keyspaces/CreateKeyspaceOperationTest.java @@ -0,0 +1,153 @@ +package io.stargate.sgv2.jsonapi.service.operation.keyspaces; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.datastax.oss.driver.api.core.CqlIdentifier; +import com.datastax.oss.driver.api.core.cql.AsyncResultSet; +import com.datastax.oss.driver.api.core.cql.SimpleStatement; +import io.smallrye.mutiny.Uni; +import io.stargate.sgv2.jsonapi.api.request.RequestContext; +import io.stargate.sgv2.jsonapi.service.cqldriver.executor.QueryExecutor; +import java.util.Collections; +import java.util.Map; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +/** + * Verifies the keyspace CQL passed to the query executor. The operation gets a safe keyspace + * identifier from the resolver; replication map keys are still validated before this point. + */ +class CreateKeyspaceOperationTest { + + @Nested + class SimpleStrategy { + + @Test + public void defaultsToReplicationFactorOne() { + var op = new CreateKeyspaceOperation(identifier("red_star_belgrade"), null, null); + String cql = createCql(op); + assertThat(cql) + .contains("CREATE KEYSPACE IF NOT EXISTS") + .contains("red_star_belgrade") + .contains("'class':'SimpleStrategy'") + .contains("'replication_factor':1"); + } + + @Test + public void honoursExplicitReplicationFactor() { + var op = + new CreateKeyspaceOperation( + identifier("red_star_belgrade"), "SimpleStrategy", Map.of("replication_factor", 5)); + String cql = createCql(op); + assertThat(cql).contains("'class':'SimpleStrategy'").contains("'replication_factor':5"); + } + + @Test + public void unknownStrategyFallsBackToSimple() { + var op = new CreateKeyspaceOperation(identifier("k"), "SomeOtherStrategy", null); + String cql = createCql(op); + assertThat(cql).contains("'class':'SimpleStrategy'"); + } + + @Test + public void rejectsNullReplicationFactorValue() { + var op = + new CreateKeyspaceOperation( + identifier("ks"), + "SimpleStrategy", + Collections.singletonMap("replication_factor", null)); + + Throwable throwable = catchThrowable(() -> createCql(op)); + + assertThat(throwable) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("replication_factor"); + } + } + + @Nested + class NetworkTopologyStrategy { + + @Test + public void supportsRealisticCloudDataCenterNames() { + var op = + new CreateKeyspaceOperation( + identifier("ks"), "NetworkTopologyStrategy", Map.of("us-east-1", 3)); + String cql = createCql(op); + assertThat(cql).contains("'us-east-1':3"); + } + + @Test + public void allowsEmptyDataCenterMap() { + var op = new CreateKeyspaceOperation(identifier("ks"), "NetworkTopologyStrategy", Map.of()); + String cql = createCql(op); + assertThat(cql).contains("'class':'NetworkTopologyStrategy'"); + } + + @Test + public void strategyNameIsCaseSensitive() { + var op = + new CreateKeyspaceOperation( + identifier("ks"), "networktopologystrategy", Map.of("dc1", 3)); + String cql = createCql(op); + assertThat(cql).contains("'class':'SimpleStrategy'").doesNotContain("'dc1':3"); + } + + @Test + public void rejectsNullDataCenterReplicationFactorValue() { + var op = + new CreateKeyspaceOperation( + identifier("ks"), "NetworkTopologyStrategy", Collections.singletonMap("dc1", null)); + + Throwable throwable = catchThrowable(() -> createCql(op)); + + assertThat(throwable) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dc1"); + } + } + + @Nested + class KeyspaceIdentifier { + + @Test + public void unquotedForSimpleAsciiName() { + var op = new CreateKeyspaceOperation(identifier("simple_name"), null, null); + String cql = createCql(op); + assertThat(cql).contains("CREATE KEYSPACE IF NOT EXISTS simple_name"); + } + + @Test + public void escapesEmbeddedDoubleQuote() { + var op = new CreateKeyspaceOperation(identifier("foo\"bar"), null, null); + String cql = createCql(op); + assertThat(cql).contains("\"foo\"\"bar\""); + } + } + + private static CqlIdentifier identifier(String name) { + return CqlIdentifier.fromInternal(name); + } + + private static String createCql(CreateKeyspaceOperation op) { + var requestContext = mock(RequestContext.class); + var queryExecutor = mock(QueryExecutor.class); + var resultSet = mock(AsyncResultSet.class); + when(resultSet.wasApplied()).thenReturn(true); + when(queryExecutor.executeCreateSchemaChange(eq(requestContext), any(SimpleStatement.class))) + .thenReturn(Uni.createFrom().item(resultSet)); + + op.execute(requestContext, queryExecutor).await().indefinitely(); + + var statement = ArgumentCaptor.forClass(SimpleStatement.class); + verify(queryExecutor).executeCreateSchemaChange(eq(requestContext), statement.capture()); + return statement.getValue().getQuery(); + } +} diff --git a/src/test/java/io/stargate/sgv2/jsonapi/service/operation/keyspaces/DropKeyspaceOperationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/service/operation/keyspaces/DropKeyspaceOperationTest.java new file mode 100644 index 0000000000..301ff8de7d --- /dev/null +++ b/src/test/java/io/stargate/sgv2/jsonapi/service/operation/keyspaces/DropKeyspaceOperationTest.java @@ -0,0 +1,53 @@ +package io.stargate.sgv2.jsonapi.service.operation.keyspaces; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.datastax.oss.driver.api.core.CqlIdentifier; +import com.datastax.oss.driver.api.core.cql.AsyncResultSet; +import com.datastax.oss.driver.api.core.cql.SimpleStatement; +import io.smallrye.mutiny.Uni; +import io.stargate.sgv2.jsonapi.api.request.RequestContext; +import io.stargate.sgv2.jsonapi.service.cqldriver.executor.QueryExecutor; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class DropKeyspaceOperationTest { + + @Test + public void buildsExpectedCqlForSimpleName() { + var op = new DropKeyspaceOperation(identifier("red_star_belgrade")); + String cql = dropCql(op); + assertThat(cql).contains("DROP KEYSPACE IF EXISTS").contains("red_star_belgrade"); + } + + @Test + public void escapesEmbeddedDoubleQuoteInIdentifier() { + var op = new DropKeyspaceOperation(identifier("foo\"bar")); + String cql = dropCql(op); + assertThat(cql).contains("\"foo\"\"bar\"").doesNotContain("\"foo\"bar\""); + } + + private static CqlIdentifier identifier(String name) { + return CqlIdentifier.fromInternal(name); + } + + private static String dropCql(DropKeyspaceOperation op) { + var requestContext = mock(RequestContext.class); + var queryExecutor = mock(QueryExecutor.class); + var resultSet = mock(AsyncResultSet.class); + when(resultSet.wasApplied()).thenReturn(true); + when(queryExecutor.executeDropSchemaChange(eq(requestContext), any(SimpleStatement.class))) + .thenReturn(Uni.createFrom().item(resultSet)); + + op.execute(requestContext, queryExecutor).await().indefinitely(); + + var statement = ArgumentCaptor.forClass(SimpleStatement.class); + verify(queryExecutor).executeDropSchemaChange(eq(requestContext), statement.capture()); + return statement.getValue().getQuery(); + } +} diff --git a/src/test/java/io/stargate/sgv2/jsonapi/service/resolver/CreateKeyspaceCommandResolverTest.java b/src/test/java/io/stargate/sgv2/jsonapi/service/resolver/CreateKeyspaceCommandResolverTest.java index 2c9b860b82..09bad9b36a 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/service/resolver/CreateKeyspaceCommandResolverTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/service/resolver/CreateKeyspaceCommandResolverTest.java @@ -15,6 +15,7 @@ import io.stargate.sgv2.jsonapi.service.schema.DatabaseSchemaObject; import io.stargate.sgv2.jsonapi.testresource.NoGlobalResourcesTestProfile; import jakarta.inject.Inject; +import java.util.Map; import org.apache.commons.lang3.RandomStringUtils; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; @@ -57,9 +58,9 @@ public void noOptions() throws Exception { .isInstanceOfSatisfying( CreateKeyspaceOperation.class, op -> { - assertThat(op.name()).isEqualTo("red_star_belgrade"); - assertThat(op.replicationMap()) - .isEqualTo("{'class': 'SimpleStrategy', 'replication_factor': 1}"); + assertThat(op.name().asInternal()).isEqualTo("red_star_belgrade"); + assertThat(op.strategy()).isNull(); + assertThat(op.strategyOptions()).isNull(); }); } @@ -86,9 +87,8 @@ public void simpleStrategy() throws Exception { .isInstanceOfSatisfying( CreateKeyspaceOperation.class, op -> { - assertThat(op.name()).isEqualTo("red_star_belgrade"); - assertThat(op.replicationMap()) - .isEqualTo("{'class': 'SimpleStrategy', 'replication_factor': 1}"); + assertThat(op.name().asInternal()).isEqualTo("red_star_belgrade"); + assertThat(op.strategy()).isEqualTo("SimpleStrategy"); }); } @@ -116,9 +116,9 @@ public void simpleStrategyWithReplication() throws Exception { .isInstanceOfSatisfying( CreateKeyspaceOperation.class, op -> { - assertThat(op.name()).isEqualTo("red_star_belgrade"); - assertThat(op.replicationMap()) - .isEqualTo("{'class': 'SimpleStrategy', 'replication_factor': 2}"); + assertThat(op.name().asInternal()).isEqualTo("red_star_belgrade"); + assertThat(op.strategy()).isEqualTo("SimpleStrategy"); + assertThat(op.strategyOptions()).containsEntry("replication_factor", 2); }); } @@ -147,11 +147,11 @@ public void networkTopologyStrategy() throws Exception { .isInstanceOfSatisfying( CreateKeyspaceOperation.class, op -> { - assertThat(op.name()).isEqualTo("red_star_belgrade"); - assertThat(op.replicationMap()) - .isIn( - "{'class': 'NetworkTopologyStrategy', 'Boston': 2, 'Berlin': 3}", - "{'class': 'NetworkTopologyStrategy', 'Berlin': 3, 'Boston': 2}"); + assertThat(op.name().asInternal()).isEqualTo("red_star_belgrade"); + assertThat(op.strategy()).isEqualTo("NetworkTopologyStrategy"); + assertThat(op.strategyOptions()) + .containsEntry("Boston", 2) + .containsEntry("Berlin", 3); }); } @@ -179,8 +179,8 @@ public void networkTopologyStrategyNoDataCenter() throws Exception { .isInstanceOfSatisfying( CreateKeyspaceOperation.class, op -> { - assertThat(op.name()).isEqualTo("red_star_belgrade"); - assertThat(op.replicationMap()).isEqualTo("{'class': 'NetworkTopologyStrategy'}"); + assertThat(op.name().asInternal()).isEqualTo("red_star_belgrade"); + assertThat(op.strategy()).isEqualTo("NetworkTopologyStrategy"); }); } @@ -205,9 +205,8 @@ public void createKeyspaceWithSupportedName() throws Exception { .isInstanceOfSatisfying( CreateKeyspaceOperation.class, op -> { - assertThat(op.name()).isEqualTo(name); - assertThat(op.replicationMap()) - .isEqualTo("{'class': 'SimpleStrategy', 'replication_factor': 1}"); + assertThat(op.name().asInternal()).isEqualTo(name); + assertThat(op.strategy()).isNull(); }); } } @@ -348,6 +347,142 @@ public void createKeyspaceWithSpecialCharacter() throws Exception { "The supported Keyspace names must not be empty, more than 48 characters long, or contain non-alphanumeric-underscore characters.", "The command used the unsupported Keyspace name: '!@-'."); } + + @Test + public void rejectsInjectionInDataCenterName() throws Exception { + // The driver does not escape NetworkTopologyStrategy map keys, so reject names that could + // break out of the generated CQL string literal. + String json = + """ + { + "createNamespace": { + "name" : "red_star_belgrade", + "options": { + "replication": { + "class": "NetworkTopologyStrategy", + "dc1', 'class': 'SimpleStrategy" : 1 + } + } + } + } + """; + + CreateNamespaceCommand command = objectMapper.readValue(json, CreateNamespaceCommand.class); + Throwable throwable = catchThrowable(() -> resolver.resolveCommand(commandContext, command)); + + verifySchemaException( + throwable, + SchemaException.Code.INVALID_REPLICATION_DATA_CENTER_NAME, + "data center name in the NetworkTopologyStrategy replication options that is not valid", + "must not be empty or contain ASCII single quote characters", + "The command used the invalid data center name: 'dc1', 'class': 'SimpleStrategy'."); + } + + @Test + public void allowsDataCenterNameWithSpace() throws Exception { + String json = + """ + { + "createNamespace": { + "name" : "red_star_belgrade", + "options": { + "replication": { + "class": "NetworkTopologyStrategy", + "dc one" : 1 + } + } + } + } + """; + + CreateNamespaceCommand command = objectMapper.readValue(json, CreateNamespaceCommand.class); + Operation result = resolver.resolveCommand(commandContext, command); + + assertThat(result) + .isInstanceOfSatisfying( + CreateKeyspaceOperation.class, + op -> assertThat(op.strategyOptions()).containsEntry("dc one", 1)); + } + + @Test + public void allowsDataCenterNameWithoutLengthLimit() throws Exception { + String dcName = RandomStringUtils.insecure().nextAlphabetic(49); + String json = + """ + { + "createNamespace": { + "name" : "red_star_belgrade", + "options": { + "replication": { + "class": "NetworkTopologyStrategy", + "%s" : 1 + } + } + } + } + """ + .formatted(dcName); + + CreateNamespaceCommand command = objectMapper.readValue(json, CreateNamespaceCommand.class); + Operation result = resolver.resolveCommand(commandContext, command); + + assertThat(result) + .isInstanceOfSatisfying( + CreateKeyspaceOperation.class, + op -> assertThat(op.strategyOptions()).containsEntry(dcName, 1)); + } + } + + @Nested + class CreateKeyspaceDataCenterNameSuccess { + + @Test + public void hyphenatedDataCenterNameIsAllowed() throws Exception { + // Cloud-style DC names (Astra, AWS regions) contain hyphens and must keep working. + String json = + """ + { + "createNamespace": { + "name" : "red_star_belgrade", + "options": { + "replication": { + "class": "NetworkTopologyStrategy", + "us-east-1" : 3 + } + } + } + } + """; + + CreateNamespaceCommand command = objectMapper.readValue(json, CreateNamespaceCommand.class); + Operation result = resolver.resolveCommand(commandContext, command); + + assertThat(result) + .isInstanceOfSatisfying( + CreateKeyspaceOperation.class, + op -> { + assertThat(op.strategy()).isEqualTo("NetworkTopologyStrategy"); + assertThat(op.strategyOptions()).containsEntry("us-east-1", 3); + }); + } + + @Test + public void onlyAsciiSingleQuoteIsRejectedAsCqlStringDelimiter() { + String dcName = "dc`\"\u2018\u2019\u201C\u201D"; + CreateNamespaceCommand command = + new CreateNamespaceCommand( + "red_star_belgrade", + new CreateNamespaceCommand.Options( + new CreateNamespaceCommand.Replication( + "NetworkTopologyStrategy", Map.of(dcName, 1)))); + + Operation result = resolver.resolveCommand(commandContext, command); + + assertThat(result) + .isInstanceOfSatisfying( + CreateKeyspaceOperation.class, + op -> assertThat(op.strategyOptions()).containsEntry(dcName, 1)); + } } private void verifySchemaException( diff --git a/src/test/java/io/stargate/sgv2/jsonapi/service/resolver/DropKeyspaceCommandResolverTest.java b/src/test/java/io/stargate/sgv2/jsonapi/service/resolver/DropKeyspaceCommandResolverTest.java index a1b6ab8dbd..93c7acd730 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/service/resolver/DropKeyspaceCommandResolverTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/service/resolver/DropKeyspaceCommandResolverTest.java @@ -1,13 +1,16 @@ package io.stargate.sgv2.jsonapi.service.resolver; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; import com.fasterxml.jackson.databind.ObjectMapper; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.junit.TestProfile; import io.stargate.sgv2.jsonapi.TestConstants; import io.stargate.sgv2.jsonapi.api.model.command.CommandContext; +import io.stargate.sgv2.jsonapi.api.model.command.impl.DropKeyspaceCommand; import io.stargate.sgv2.jsonapi.api.model.command.impl.DropNamespaceCommand; +import io.stargate.sgv2.jsonapi.exception.SchemaException; import io.stargate.sgv2.jsonapi.service.operation.Operation; import io.stargate.sgv2.jsonapi.service.operation.keyspaces.DropKeyspaceOperation; import io.stargate.sgv2.jsonapi.service.schema.DatabaseSchemaObject; @@ -22,6 +25,7 @@ class DropKeyspaceCommandResolverTest { @Inject ObjectMapper objectMapper; @Inject DropNamespaceCommandResolver resolver; + @Inject DropKeyspaceCommandResolver dropKeyspaceResolver; private final TestConstants testConstants = new TestConstants(); CommandContext commandContext; @@ -48,6 +52,56 @@ public void happyPath() throws Exception { assertThat(result) .isInstanceOfSatisfying( DropKeyspaceOperation.class, - op -> assertThat(op.name()).isEqualTo("red_star_belgrade")); + op -> assertThat(op.name().asInternal()).isEqualTo("red_star_belgrade")); + } + + @Test + public void dropKeyspaceHappyPath() throws Exception { + String json = + """ + { + "dropKeyspace": { + "name" : "red_star_belgrade" + } + } + """; + + DropKeyspaceCommand command = objectMapper.readValue(json, DropKeyspaceCommand.class); + Operation result = dropKeyspaceResolver.resolveCommand(commandContext, command); + + assertThat(result) + .isInstanceOfSatisfying( + DropKeyspaceOperation.class, + op -> assertThat(op.name().asInternal()).isEqualTo("red_star_belgrade")); + } + + @Test + public void dropKeyspaceAllowsLongWordName() { + String name = "a".repeat(64); + var command = new DropKeyspaceCommand(name); + + Operation result = dropKeyspaceResolver.resolveCommand(commandContext, command); + + assertThat(result) + .isInstanceOfSatisfying( + DropKeyspaceOperation.class, op -> assertThat(op.name().asInternal()).isEqualTo(name)); + } + + @Test + public void dropKeyspaceRejectsNonWordName() { + var command = new DropKeyspaceCommand("bad-name"); + + Throwable throwable = + catchThrowable(() -> dropKeyspaceResolver.resolveCommand(commandContext, command)); + + assertThat(throwable) + .isInstanceOf(SchemaException.class) + .satisfies( + e -> { + SchemaException exception = (SchemaException) e; + assertThat(exception.code) + .isEqualTo(SchemaException.Code.UNSUPPORTED_SCHEMA_NAME.name()); + assertThat(exception.getMessage()).contains("bad-name"); + }); } } diff --git a/src/test/java/io/stargate/sgv2/jsonapi/service/schema/NamingRulesTests.java b/src/test/java/io/stargate/sgv2/jsonapi/service/schema/NamingRulesTests.java index 3b0ec43607..216f11085b 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/service/schema/NamingRulesTests.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/service/schema/NamingRulesTests.java @@ -1,7 +1,9 @@ package io.stargate.sgv2.jsonapi.service.schema; import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.catchThrowable; +import io.stargate.sgv2.jsonapi.exception.SchemaException; import io.stargate.sgv2.jsonapi.service.schema.naming.NamingRules; import io.stargate.sgv2.jsonapi.service.schema.naming.SchemaObjectNamingRule; import java.util.Arrays; @@ -26,6 +28,32 @@ public void schemaObjectNamingValidation( assertThat(namingRule.apply(invalidName)).as(description).isEqualTo(expectedResult); } + @ParameterizedTest + @MethodSource("schemaObjectNamingRules") + public void sanitizeInputAllowsNamesLongerThanMaxLength(SchemaObjectNamingRule namingRule) { + String name = "a".repeat(namingRule.getMaxLength() + 1); + + assertThat(namingRule.sanitizeInput(name)).isEqualTo(name); + } + + @ParameterizedTest + @MethodSource("schemaObjectNamingRules") + public void sanitizeInputRejectsUnsupportedCharacters(SchemaObjectNamingRule namingRule) { + String name = "bad-name"; + + Throwable throwable = catchThrowable(() -> namingRule.sanitizeInput(name)); + + assertThat(throwable) + .isInstanceOf(SchemaException.class) + .satisfies( + e -> { + SchemaException exception = (SchemaException) e; + assertThat(exception.code) + .isEqualTo(SchemaException.Code.UNSUPPORTED_SCHEMA_NAME.name()); + assertThat(exception.getMessage()).contains(name); + }); + } + private static Stream schemaObjectNamingTestCases() { // Define a simple record to encapsulate a test case. record TestCase(String name, boolean expected, String description) {} @@ -80,4 +108,12 @@ record TestCase(String name, boolean expected, String description) {} return Stream.concat(generalTestCases, specialTestCases); } + + private static Stream schemaObjectNamingRules() { + return Stream.of( + Arguments.of(NamingRules.KEYSPACE), + Arguments.of(NamingRules.COLLECTION), + Arguments.of(NamingRules.TABLE), + Arguments.of(NamingRules.INDEX)); + } }