diff --git a/common/src/main/java/org/eclipse/ditto/testing/common/HttpResource.java b/common/src/main/java/org/eclipse/ditto/testing/common/HttpResource.java index 9207128..11a2736 100644 --- a/common/src/main/java/org/eclipse/ditto/testing/common/HttpResource.java +++ b/common/src/main/java/org/eclipse/ditto/testing/common/HttpResource.java @@ -71,7 +71,9 @@ public enum HttpResource { WHOAMI("/whoami"), - CHECK_PERMISSIONS("/checkPermissions"); + CHECK_PERMISSIONS("/checkPermissions"), + + MIGRATE_DEFINITION("/migrateDefinition"); private final String path; diff --git a/common/src/main/java/org/eclipse/ditto/testing/common/IntegrationTest.java b/common/src/main/java/org/eclipse/ditto/testing/common/IntegrationTest.java index bb812c4..f64b300 100644 --- a/common/src/main/java/org/eclipse/ditto/testing/common/IntegrationTest.java +++ b/common/src/main/java/org/eclipse/ditto/testing/common/IntegrationTest.java @@ -1171,6 +1171,19 @@ protected static DeleteMatcher deleteDefinition(final int version, final CharSeq return delete(dittoUrl(version, path)).withLogging(LOGGER, "FeatureDefinition"); } + /** + * Sends a POST request to the /migrateDefinition endpoint with the specified JSON payload. + * + * @param payload the JSON payload to post. + * @return the wrapped request. + */ + protected static PostMatcher postMigrateDefinition(final CharSequence thingId, @Nullable final String payload, final boolean dryRun) { + final String path = ResourcePathBuilder.forThing(thingId).migrateDefinition().toString(); + return post(dittoUrl(TestConstants.API_V_2, path), payload) + .withParam("dry-run", String.valueOf(dryRun)) + .withLogging(LOGGER, "MigrateDefinition"); + } + /** * Puts a Feature's Properties. * diff --git a/common/src/main/java/org/eclipse/ditto/testing/common/ResourcePathBuilder.java b/common/src/main/java/org/eclipse/ditto/testing/common/ResourcePathBuilder.java index ad407f6..e0b5c0d 100644 --- a/common/src/main/java/org/eclipse/ditto/testing/common/ResourcePathBuilder.java +++ b/common/src/main/java/org/eclipse/ditto/testing/common/ResourcePathBuilder.java @@ -172,6 +172,11 @@ public Thing definition() { return this; } + public Thing migrateDefinition() { + stringBuilder.append(HttpResource.MIGRATE_DEFINITION); + return this; + } + } public final class Policy extends AbstractPathBuilder { diff --git a/system/src/test/java/org/eclipse/ditto/testing/system/connectivity/AbstractConnectivityITestCases.java b/system/src/test/java/org/eclipse/ditto/testing/system/connectivity/AbstractConnectivityITestCases.java index b4ef1eb..e6dc4dd 100644 --- a/system/src/test/java/org/eclipse/ditto/testing/system/connectivity/AbstractConnectivityITestCases.java +++ b/system/src/test/java/org/eclipse/ditto/testing/system/connectivity/AbstractConnectivityITestCases.java @@ -136,6 +136,8 @@ import org.eclipse.ditto.things.model.signals.commands.modify.DeleteThing; import org.eclipse.ditto.things.model.signals.commands.modify.MergeThing; import org.eclipse.ditto.things.model.signals.commands.modify.MergeThingResponse; +import org.eclipse.ditto.things.model.signals.commands.modify.MigrateThingDefinition; +import org.eclipse.ditto.things.model.signals.commands.modify.MigrateThingDefinitionResponse; import org.eclipse.ditto.things.model.signals.commands.modify.ModifyAttribute; import org.eclipse.ditto.things.model.signals.commands.modify.ModifyAttributeResponse; import org.eclipse.ditto.things.model.signals.commands.modify.ModifyFeature; @@ -149,6 +151,7 @@ import org.eclipse.ditto.things.model.signals.events.ThingDeleted; import org.eclipse.ditto.things.model.signals.events.ThingEvent; import org.eclipse.ditto.things.model.signals.events.ThingMerged; +import org.eclipse.ditto.things.model.signals.events.ThingDefinitionMigrated; import org.eclipse.ditto.things.model.signals.events.ThingModified; import org.eclipse.ditto.thingsearch.model.signals.commands.subscription.CreateSubscription; import org.eclipse.ditto.thingsearch.model.signals.commands.subscription.RequestFromSubscription; @@ -175,6 +178,7 @@ public abstract class AbstractConnectivityITestCases extends private static final String EMPTY_MOD = "EMPTY_MOD"; private static final String FILTER_FOR_LIFECYCLE_EVENTS_BY_RQL = "FILTER_FOR_LIFECYCLE_EVENTS_BY_RQL"; private static final String FILTER_POLICY_ANNOUNCEMENTS_BY_NAMESPACE = "FILTER_BY_NAMESPACE"; + private static final String THING_DEFINITION_URL = "https://eclipse-ditto.github.io/ditto-examples/wot/models/dimmable-colored-lamp-1.0.0.tm.jsonld"; static { // adds filtering for twin events - only "created" and "deleted" ones @@ -331,6 +335,64 @@ public void receiveSubjectDeletionNotification() { assertThat(deleteAnnouncement.getSubjectIds()).containsExactly(subjectId); } + @Test + @Category(RequireSource.class) + @Connections({CONNECTION1, CONNECTION2}) + public void sendMigrateThingDefinitionAndEnsureEventsAreProduced() { + // Given + final String correlationId = createNewCorrelationId(); + final ThingId thingId = generateThingId(); + final Thing thing = Thing.newBuilder() + .setId(thingId) + .build(); + final Policy policy = Policy.newBuilder() + .forLabel("DEFAULT") + .setSubject(testingContextWithRandomNs.getOAuthClient().getDefaultSubject()) + .setSubject(connectionSubject(cf.connectionName1)) + .setGrantedPermissions(PoliciesResourceType.thingResource("/"), READ, WRITE) + .setGrantedPermissions(PoliciesResourceType.policyResource("/"), READ, WRITE) + .setGrantedPermissions(PoliciesResourceType.messageResource("/"), READ, WRITE) + .forLabel("RESTRICTED") + .setSubject(connectionSubject(cf.connectionName2)) + .setGrantedPermissions(PoliciesResourceType.thingResource("/"), READ) + .setGrantedPermissions(PoliciesResourceType.policyResource("/"), READ) + .setGrantedPermissions(PoliciesResourceType.messageResource("/"), READ) + .build(); + + final JsonObject migrationPayload = JsonObject.newBuilder() + .set(MigrateThingDefinition.JsonFields.JSON_THING_DEFINITION_URL, THING_DEFINITION_URL) + .build(); + final MigrateThingDefinition migrateThingDefinition = MigrateThingDefinition.of( + thingId, THING_DEFINITION_URL, migrationPayload, Collections.emptyMap(), true,createDittoHeaders(correlationId)); + + // When + final C eventConsumer = initTargetsConsumer(cf.connectionName2); + putThingWithPolicy(2, thing, policy, JsonSchemaVersion.V_2) + .withCorrelationId(correlationId) + .withJWT(testingContextWithRandomNs.getOAuthClient().getAccessToken()) + .expectingHttpStatus(HttpStatus.CREATED) + .fire(); + + final CommandResponse commandResponse = + sendCommandAndEnsureResponseIsSentBack(cf.connectionName1, migrateThingDefinition); + LOGGER.info("Received response: {}", commandResponse); + assertThat(commandResponse).isInstanceOf(MigrateThingDefinitionResponse.class); + assertThat(commandResponse.getHttpStatus()).isEqualTo(HttpStatus.OK); + + // Then wait for all events generated (order independent) + consumeAndAssertEvents(cf.connectionName2, eventConsumer, Arrays.asList( + e -> { + LOGGER.info("Received event: {}", e); + final ThingCreated tc = thingEventForJson(e, ThingCreated.class, correlationId, thingId); + assertThat(tc.getRevision()).isEqualTo(1L); + }, + e -> { + LOGGER.info("Received event: {}", e); + final ThingDefinitionMigrated tm = thingEventForJson(e, ThingDefinitionMigrated.class, correlationId, thingId); + assertThat(tm.getRevision()).isEqualTo(2L); + }), "ThingCreated", "ThingModified"); + } + @Test @UseConnection(category = ConnectionCategory.CONNECTION1, mod = FILTER_POLICY_ANNOUNCEMENTS_BY_NAMESPACE) public void receiveSubjectDeletionNotificationForNamespace() { diff --git a/system/src/test/java/org/eclipse/ditto/testing/system/things/rest/MigrateThingDefinitionIT.java b/system/src/test/java/org/eclipse/ditto/testing/system/things/rest/MigrateThingDefinitionIT.java new file mode 100644 index 0000000..02295f3 --- /dev/null +++ b/system/src/test/java/org/eclipse/ditto/testing/system/things/rest/MigrateThingDefinitionIT.java @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.testing.system.things.rest; + +import org.eclipse.ditto.base.model.common.HttpStatus; +import org.eclipse.ditto.base.model.json.JsonSchemaVersion; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.policies.api.Permission; +import org.eclipse.ditto.policies.model.PoliciesResourceType; +import org.eclipse.ditto.policies.model.Policy; +import org.eclipse.ditto.policies.model.Subject; +import org.eclipse.ditto.policies.model.SubjectIssuer; +import org.eclipse.ditto.testing.common.IntegrationTest; +import org.eclipse.ditto.testing.common.TestConstants; +import org.eclipse.ditto.testing.common.client.BasicAuth; +import org.eclipse.ditto.things.model.Thing; +import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.things.model.ThingsModelFactory; +import org.eclipse.ditto.things.model.signals.commands.ThingCommand; +import org.eclipse.ditto.things.model.signals.commands.modify.MigrateThingDefinition; +import org.eclipse.ditto.things.model.signals.commands.modify.MigrateThingDefinitionResponse; +import org.junit.BeforeClass; +import org.junit.Test; +import org.eclipse.ditto.policies.model.Subjects; +import org.junit.FixMethodOrder; +import org.junit.runners.MethodSorters; + +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +public final class MigrateThingDefinitionIT extends IntegrationTest { + + private static ThingId testThingId; + private static Policy testPolicy; + private static final String THING_DEFINITION_URL = "https://eclipse-ditto.github.io/ditto-examples/wot/models/dimmable-colored-lamp-1.0.0.tm.jsonld"; + + @BeforeClass + public static void setup() { + testThingId = ThingId.of(idGenerator().withRandomName()); + testPolicy = createPolicyForMigration(); + + putThingWithPolicy(TestConstants.API_V_2, newThing(testThingId), testPolicy, JsonSchemaVersion.V_2) + .expectingHttpStatus(HttpStatus.CREATED) + .fire(); + } + + @Test + public void test1_MigrateDefinitionDryRun() { + final JsonObject migrationPayload = buildMigrationPayload(); + + postMigrateDefinition(testThingId, migrationPayload.toString(), true) + .expectingHttpStatus(HttpStatus.ACCEPTED) + .expectingBody(contains(buildExpectedResponse(testThingId, true))) + .fire(); + } + + @Test + public void test2_MigrateDefinitionSuccess() { + final JsonObject migrationPayload = buildMigrationPayload(); + + postMigrateDefinition(testThingId, migrationPayload.toString(), false) + .expectingHttpStatus(HttpStatus.OK) + .expectingBody(contains(buildExpectedResponse(testThingId, false))) + .fire(); + } + + @Test + public void test4_MigrateDefinitionWithWotValidationError() { + final JsonObject migrationPayload = JsonObject.newBuilder() + .set(MigrateThingDefinition.JsonFields.JSON_THING_DEFINITION_URL, THING_DEFINITION_URL) + .set(MigrateThingDefinition.JsonFields.JSON_MIGRATION_PAYLOAD, JsonFactory.newObjectBuilder() + .set("attributes", JsonFactory.newObjectBuilder() + .set("dimmer-level", JsonFactory.nullLiteral()) + .build()) + .build()) + .build(); + + postMigrateDefinition(testThingId, migrationPayload.toString(), false) + .expectingHttpStatus(HttpStatus.BAD_REQUEST) + .expectingBody(contains(JsonObject.newBuilder().set("error", "wot:payload.validation.error").build())) + .fire(); + } + + @Test + public void test5_MigrateDefinitionWithWotValidationError() { + final JsonObject migrationPayload = JsonObject.newBuilder() + .set(MigrateThingDefinition.JsonFields.JSON_THING_DEFINITION_URL, THING_DEFINITION_URL) + .set(MigrateThingDefinition.JsonFields.JSON_MIGRATION_PAYLOAD, JsonFactory.newObjectBuilder() + .set("attributes", JsonFactory.newObjectBuilder() + .set("dimmer-level", 0.5) + .build()) + .build()) + .set(MigrateThingDefinition.JsonFields.JSON_PATCH_CONDITIONS, JsonObject.newBuilder() + .set("thing:/features/thermostat", "eq(attributes/dimmer-level,1.0)") + .build()) + .build(); + + postMigrateDefinition(testThingId, migrationPayload.toString(), false) + .expectingHttpStatus(HttpStatus.OK) + .expectingBody(contains(JsonObject.newBuilder() + .set(ThingCommand.JsonFields.JSON_THING_ID, testThingId.toString()) + .set(MigrateThingDefinitionResponse.JsonFields.JSON_PATCH, JsonFactory.newObjectBuilder() + .set("definition", THING_DEFINITION_URL) + .set("attributes", JsonFactory.newObjectBuilder() + .set("dimmer-level", 0.5) + .build()) + .build()) + .set(MigrateThingDefinitionResponse.JsonFields.JSON_MERGE_STATUS, "APPLIED") + .build())) + .fire(); + } + + @Test + public void test6_MigrateDefinitionWithInvalidThingId() { + final JsonObject migrationPayload = buildMigrationPayload(); + + postMigrateDefinition(serviceEnv.getDefaultNamespaceName() + ":unknownThingId", migrationPayload.toString(), false) + .expectingHttpStatus(HttpStatus.NOT_FOUND) + .fire(); + } + + @Test + public void test7_MigrateDefinitionWithInvalidPayload() { + final JsonObject invalidPayload = JsonFactory.newObjectBuilder() + .set("invalid_field", "some_value") + .build(); + + postMigrateDefinition(testThingId, invalidPayload.toString(), false) + .expectingHttpStatus(HttpStatus.BAD_REQUEST) + .fire(); + } + + private static Thing newThing(final ThingId thingId) { + return ThingsModelFactory.newThingBuilder() + .setId(thingId) + .setAttribute(JsonPointer.of("manufacturer"), JsonValue.of("Old Corp")) + .build(); + } + + private static JsonObject buildMigrationPayload() { + return JsonObject.newBuilder() + .set(MigrateThingDefinition.JsonFields.JSON_THING_DEFINITION_URL, THING_DEFINITION_URL) + .set(MigrateThingDefinition.JsonFields.JSON_MIGRATION_PAYLOAD, JsonFactory.newObjectBuilder() + .set("attributes", JsonFactory.newObjectBuilder() + .set("manufacturer", JsonFactory.nullLiteral()) + .set("color", JsonFactory.newObjectBuilder() + .set("g", 5) + .build()) + .set("dimmer-level", 1.0) + .build()) + .build()) + .set(MigrateThingDefinition.JsonFields.JSON_INITIALIZE_MISSING_PROPERTIES_FROM_DEFAULTS, true) + .build(); + } + + private static JsonObject buildExpectedResponse(ThingId thingId, boolean dryRun) { + return JsonObject.newBuilder() + .set(ThingCommand.JsonFields.JSON_THING_ID, thingId.toString()) + .set(MigrateThingDefinitionResponse.JsonFields.JSON_PATCH, JsonFactory.newObjectBuilder() + .set("definition", THING_DEFINITION_URL) + .set("attributes", JsonFactory.newObjectBuilder() + .set("manufacturer", JsonFactory.nullLiteral()) + .set("on", false) + .set("color", JsonFactory.newObjectBuilder() + .set("r", 0) + .set("g", 5) + .set("b", 0) + .build()) + .set("dimmer-level", 1.0) + .build()) + .build()) + .set(MigrateThingDefinitionResponse.JsonFields.JSON_MERGE_STATUS, dryRun ? "DRY_RUN" : "APPLIED") + .build(); + } + + private static Policy createPolicyForMigration() { + final BasicAuth basicAuth = serviceEnv.getDefaultTestingContext().getBasicAuth(); + final Subjects subjects; + if (basicAuth.isEnabled()) { + subjects = Subjects.newInstance(Subject.newInstance( + SubjectIssuer.newInstance("nginx"), basicAuth.getUsername())); + } else { + subjects = Subjects.newInstance( + serviceEnv.getDefaultTestingContext().getOAuthClient().getSubject(), + serviceEnv.getTestingContext2().getOAuthClient().getSubject()); + } + return Policy.newBuilder() + .forLabel("DEFAULT") + .setSubjects(subjects) + .setGrantedPermissions(PoliciesResourceType.thingResource("/"), Permission.WRITE, Permission.READ) + .setGrantedPermissions(PoliciesResourceType.policyResource("/"), Permission.WRITE, Permission.READ) + .build(); + } +} diff --git a/system/src/test/java/org/eclipse/ditto/testing/system/things/ws/WebsocketIT.java b/system/src/test/java/org/eclipse/ditto/testing/system/things/ws/WebsocketIT.java index 92d2fa0..3b6d807 100644 --- a/system/src/test/java/org/eclipse/ditto/testing/system/things/ws/WebsocketIT.java +++ b/system/src/test/java/org/eclipse/ditto/testing/system/things/ws/WebsocketIT.java @@ -23,6 +23,7 @@ import java.time.Instant; import java.util.Arrays; import java.util.Base64; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -111,6 +112,8 @@ import org.eclipse.ditto.things.model.signals.commands.modify.DeleteThing; import org.eclipse.ditto.things.model.signals.commands.modify.DeleteThingResponse; import org.eclipse.ditto.things.model.signals.commands.modify.MergeThing; +import org.eclipse.ditto.things.model.signals.commands.modify.MigrateThingDefinition; +import org.eclipse.ditto.things.model.signals.commands.modify.MigrateThingDefinitionResponse; import org.eclipse.ditto.things.model.signals.commands.modify.ModifyAttribute; import org.eclipse.ditto.things.model.signals.commands.modify.ModifyFeature; import org.eclipse.ditto.things.model.signals.commands.modify.ModifyFeatureDesiredPropertyResponse; @@ -139,6 +142,8 @@ public final class WebsocketIT extends IntegrationTest { private static final Logger LOGGER = LoggerFactory.getLogger(WebsocketIT.class); private static final long LATCH_TIMEOUT_SECONDS = 30; private static final AtomicInteger ACK_COUNTER = new AtomicInteger(0); + private static final String THING_DEFINITION_URL = "https://eclipse-ditto.github.io/ditto-examples/wot/models/dimmable-colored-lamp-1.0.0.tm.jsonld"; + // not static: refresh correlation ID for each test private final DittoHeaders COMMAND_HEADERS_V2 = DittoHeaders.newBuilder() @@ -409,6 +414,42 @@ public void retrieveThing() throws InterruptedException, TimeoutException, Execu } + @Test + @Category(Acceptance.class) + public void migrateThingDefinition() throws InterruptedException, TimeoutException, ExecutionException { + final ThingId thingId = ThingId.of(idGenerator(testingContext1.getSolution().getDefaultNamespace()).withRandomName()); + + final Thing thing = Thing.newBuilder() + .setId(thingId) + .build(); + + final CreateThing createThing = CreateThing.of(thing, newPolicy(PolicyId.of(thingId), user1OAuthClient, user2OAuthClient).toJson(), + commandHeadersWithOwnCorrelationId()); + + clientUser1.send(createThing).whenComplete((commandResponse, throwable) -> { + assertThat(throwable).isNull(); + assertThat(commandResponse).isInstanceOf(CreateThingResponse.class); + }).get(LATCH_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + + final JsonObject migrationPayload = JsonObject.newBuilder() + .set(MigrateThingDefinition.JsonFields.JSON_THING_DEFINITION_URL, THING_DEFINITION_URL) + .build(); + final MigrateThingDefinition migrateThingDefinition = MigrateThingDefinition.of( + thingId, THING_DEFINITION_URL, migrationPayload, Collections.emptyMap(), true,COMMAND_HEADERS_V2.toBuilder() + .randomCorrelationId() + .build()); + + clientUser2.send(migrateThingDefinition).whenComplete((commandResponse, throwable) -> { + assertThat(throwable).isNull(); + assertThat(commandResponse).isInstanceOf(MigrateThingDefinitionResponse.class); + }).get(LATCH_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + clientUser1.send(DeleteThing.of(thingId, COMMAND_HEADERS_V2)); + clientUser1.send(DeletePolicy.of(PolicyId.of(thingId), COMMAND_HEADERS_V2)); + + } + @Test @Category(Acceptance.class) public void consumeThingCreated() throws InterruptedException {