diff --git a/sdk/platformvalidation/azure-resourcemanager-platformvalidation/CHANGELOG.md b/sdk/platformvalidation/azure-resourcemanager-platformvalidation/CHANGELOG.md index 581c132e74c93..c21bacdebb513 100644 --- a/sdk/platformvalidation/azure-resourcemanager-platformvalidation/CHANGELOG.md +++ b/sdk/platformvalidation/azure-resourcemanager-platformvalidation/CHANGELOG.md @@ -10,6 +10,11 @@ ### Other Changes +- Migrated handwritten scenarios to the ARM recording/playback framework with deterministic names and signed-URL sanitization. +- Added the SDK provider-registration policy to the live-test client. +- Added an optional regional ARM endpoint override for live tests, preserving public-cloud authentication. +- Bounded live-test cleanup retries for transient parent-deletion conflicts after child removal. + ## 1.0.0-beta.1 (2026-09-28) - Azure Resource Manager Platform Validation client library for Java. This package contains Microsoft Azure SDK for Platform Validation Management SDK. Microsoft.PlatformValidation Resource Provider management API. Package api-version 2026-08-01-preview. For documentation on how to use this package, please see [Azure Management Libraries for Java](https://aka.ms/azsdk/java/mgmt). @@ -17,4 +22,3 @@ ### Features Added - Initial release for the azure-resourcemanager-platformvalidation Java SDK. - diff --git a/sdk/platformvalidation/azure-resourcemanager-platformvalidation/README.md b/sdk/platformvalidation/azure-resourcemanager-platformvalidation/README.md index d4d8ba267017f..9f9186465fc32 100644 --- a/sdk/platformvalidation/azure-resourcemanager-platformvalidation/README.md +++ b/sdk/platformvalidation/azure-resourcemanager-platformvalidation/README.md @@ -81,6 +81,99 @@ See [API design][design] for general introduction on design and key concepts on For details on contributing to this repository, see the [contributing guide][cg]. +### Recorded scenario tests + +The handwritten `PlatformValidationLiveTests` use `ResourceManagerTestProxyTestBase` +for RECORD, PLAYBACK, and LIVE modes, following the +[management-plane test framework guide](https://github.com/Azure/azure-sdk-for-java/tree/main/sdk/resourcemanager/azure-resourcemanager-test). +Despite the historical class name, these scenarios are no longer marked `@LiveOnly`. +Normal Maven tests use PLAYBACK and do not require Azure credentials or a live subscription. +Recordings must be captured and published before playback can pass; missing recordings +are failures, not successful coverage. + +For RECORD or LIVE, authenticate with Azure CLI and set `AZURE_TENANT_ID`, +`AZURE_SUBSCRIPTION_ID`, and `AZURE_RESOURCE_GROUP_NAME` to a dedicated test resource group. +Set `PLATFORMVALIDATION_LOCATION=eastus2euap` for Canary. +When this setting is absent, tests default to `southcentralusstg` (South Central US Stage). +This is a configuration default, not automatic failover after a service or test failure. +Use a subscription where the provider is registered and the requested region is enabled. +For regional Canary ARM routing, also set +`PLATFORMVALIDATION_ARM_ENDPOINT=https://eastus2euap.management.azure.com`. +This overrides only the resource-manager endpoint, preserving public-cloud authentication. +Leave `PLATFORMVALIDATION_CREATE_MANAGED_GROUP` unset for the current Canary deployment: +the service provisions and deletes the managed group; no manual group or role assignment is needed. +Resource names and non-secret environment settings are recorded through the framework's +resource namer, so playback does not depend on local settings. Both the service client and +the Resource Manager client share the proxy-aware HTTP pipeline. + +```powershell +$env:AZURE_TEST_MODE = "RECORD" +$env:AZURE_TEST_LOG_LEVEL = "BASIC" +mvn -f sdk\platformvalidation\azure-resourcemanager-platformvalidation\pom.xml "-Djunit.jupiter.execution.parallel.enabled=false" "-Dtest=PlatformValidationLiveTests" test +``` + +After recording, clear credentials and service-specific environment settings and verify playback: + +```powershell +$env:AZURE_TEST_MODE = "PLAYBACK" +mvn -f sdk\platformvalidation\azure-resourcemanager-platformvalidation\pom.xml "-Djunit.jupiter.execution.parallel.enabled=false" test +``` + +Inspect recordings for secrets before publishing. Custom body sanitizers redact signed URLs, +including the VHD SAS embedded inside `planConfigurationJson`, and subscription IDs in +service-specific fields and operation headers. Playback uses a non-secret image placeholder, +never a real SAS or its wall-clock expiry. +Publish recordings to `Azure/azure-sdk-assets` and commit the updated `assets.json` pointer, +following [Update test recordings](https://github.com/Azure/azure-sdk-for-java/blob/main/sdk/core/azure-core-test/README.md#update-test-recordings). +From the repository root, with the test-proxy CLI installed: + +```powershell +test-proxy push -a sdk\platformvalidation\azure-resourcemanager-platformvalidation\assets.json +``` + +Refresh recordings for each new API-version SDK PR, then verify playback before submitting. +Playback validates the SDK against recorded responses, not the current health of the service. + +CloudValidation tests cover create, GET, both LIST scopes, PATCH, PUT update, and DELETE. +The operation-status client is checked against the completed PATCH operation. +Catalog checks assert real discovery and GET results; service errors fail rather than skip. +Plan tests require `PLATFORMVALIDATION_SOURCE_VHD_URI`, an approved Linux Gen1 X64 VHD +HTTPS URL, supplied as a secret environment variable. They check plan create, GET, LIST, +metadata PATCH, and DELETE. Boot execution additionally requires +`PLATFORMVALIDATION_RUN_EXECUTION=true` and checks execution results, VTR GET/LIST, and +child absence after execution deletion. In RECORD/LIVE, plan-only tests without an image fixture are +reported as skipped, not successful execution coverage. Explicitly enabling boot execution +without a valid image fixture fails rather than silently skipping the workload. +In PLAYBACK, both image-dependent scenarios use their recordings without fixture credentials +or boot opt-in. Do not publish an incomplete recording set as full scenario coverage. +Both plan creation and boot execution require the referenced boot test to be available in +the target environment's TestStore. Wait for its deployment and publishing before running +these scenarios; disabling boot execution alone does not bypass plan-time TestStore resolution. +If the image URL has a SAS expiry, the tests require more than 25 hours of remaining +validity before creating resources (the service requires at least one day). + +Each scenario creates unique Java-owned names; no existing CV or plan is accepted as input. +Failures retain resources and log their IDs for investigation. Parent deletion stops if a +child remains. The supplied parent resource group is never deleted by the tests. +After child deletion, CloudValidation cleanup retries only HTTP 409 +`CannotDeleteResource`, at most 30 attempts ten seconds apart, verifying the plan +list is empty before every attempt. Other failures remain failures. +For an explicitly approved environment without automatic managed-group provisioning, +`PLATFORMVALIDATION_CREATE_MANAGED_GROUP=true` creates a new, tagged `-mrg` and +deletes it only when empty after successful child cleanup. It never adopts existing groups +or grants roles; arrange service permissions separately. Do not enable this setting in +environments that automatically provision managed groups. + +Standard PR CI runs playback +using published recordings; it does not need the SDK test subscription to host the service. + +For RECORD/LIVE, the test client uses the SDK's `ProviderRegistrationPolicy` to register the resource +provider when ARM returns `MissingSubscriptionRegistration`. This requires the test +identity to have subscription-level provider registration permission; otherwise, arrange +registration separately. The policy does not handle `SubscriptionNotRegistered` or +`LocationNotAvailableForResourceType`; verify registration and regional availability +separately if those errors persist. + This project welcomes contributions and suggestions. Most contributions require you to agree to a Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us the rights to use your contribution. For details, visit . When you submit a pull request, a CLA-bot will automatically determine whether you need to provide a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions provided by the bot. You will only need to do this once across all repositories using our CLA. diff --git a/sdk/platformvalidation/azure-resourcemanager-platformvalidation/assets.json b/sdk/platformvalidation/azure-resourcemanager-platformvalidation/assets.json new file mode 100644 index 0000000000000..1fc5ecabe6490 --- /dev/null +++ b/sdk/platformvalidation/azure-resourcemanager-platformvalidation/assets.json @@ -0,0 +1,6 @@ +{ + "AssetsRepo": "Azure/azure-sdk-assets", + "AssetsRepoPrefixPath": "java", + "TagPrefix": "java/platformvalidation/azure-resourcemanager-platformvalidation", + "Tag": "java/platformvalidation/azure-resourcemanager-platformvalidation_81dc07912e" +} diff --git a/sdk/platformvalidation/azure-resourcemanager-platformvalidation/pom.xml b/sdk/platformvalidation/azure-resourcemanager-platformvalidation/pom.xml index 483182648fef9..99d4fd82bb774 100644 --- a/sdk/platformvalidation/azure-resourcemanager-platformvalidation/pom.xml +++ b/sdk/platformvalidation/azure-resourcemanager-platformvalidation/pom.xml @@ -48,6 +48,18 @@ true + + com.azure.resourcemanager + azure-resourcemanager-resources + 2.55.0 + test + + + com.azure.resourcemanager + azure-resourcemanager-test + 2.0.0-beta.4 + test + com.azure azure-core diff --git a/sdk/platformvalidation/azure-resourcemanager-platformvalidation/src/test/java/com/azure/resourcemanager/platformvalidation/PlatformValidationLiveTestHelpersTests.java b/sdk/platformvalidation/azure-resourcemanager-platformvalidation/src/test/java/com/azure/resourcemanager/platformvalidation/PlatformValidationLiveTestHelpersTests.java new file mode 100644 index 0000000000000..0017a97b31117 --- /dev/null +++ b/sdk/platformvalidation/azure-resourcemanager-platformvalidation/src/test/java/com/azure/resourcemanager/platformvalidation/PlatformValidationLiveTestHelpersTests.java @@ -0,0 +1,220 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.resourcemanager.platformvalidation; + +import com.azure.core.exception.HttpResponseException; +import com.azure.core.http.HttpMethod; +import com.azure.core.http.HttpRequest; +import com.azure.core.management.AzureEnvironment; +import com.azure.core.management.exception.ManagementError; +import com.azure.core.management.exception.ManagementException; +import com.azure.core.test.http.MockHttpResponse; +import com.azure.core.test.models.TestProxySanitizer; +import com.azure.core.test.models.TestProxySanitizerType; +import com.azure.core.test.utils.TestProxyUtils; +import com.azure.core.util.BinaryData; +import com.azure.core.util.polling.LongRunningOperationStatus; +import com.azure.core.util.polling.PollResponse; +import com.azure.core.util.polling.SyncPoller; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +public class PlatformValidationLiveTestHelpersTests { + @Test + public void parentDeletionRetriesOnlyKnownConflictAndRechecksChildren() { + AtomicInteger attempts = new AtomicInteger(); + AtomicInteger guards = new AtomicInteger(); + AtomicInteger delays = new AtomicInteger(); + PlatformValidationLiveTests.deleteWithRetry(() -> { + if (attempts.incrementAndGet() < 3) { + throw deletionError(409, "CannotDeleteResource"); + } + }, guards::incrementAndGet, delays::incrementAndGet); + Assertions.assertEquals(3, attempts.get()); + Assertions.assertEquals(3, guards.get()); + Assertions.assertEquals(2, delays.get()); + Assertions.assertThrows(AssertionError.class, () -> PlatformValidationLiveTests.deleteWithRetry(() -> { + throw deletionError(409, "CannotDeleteResource"); + }, () -> { + throw new AssertionError("A child remains."); + }, () -> Assertions.fail("Must not retry when a child remains."))); + } + + @Test + public void parentDeletionDoesNotRetryUnrelatedErrors() { + for (ManagementException error : new ManagementException[] { + deletionError(403, "AuthorizationFailed"), + deletionError(409, "AnotherConflict") }) { + AtomicInteger attempts = new AtomicInteger(); + Assertions.assertSame(error, Assertions.assertThrows(ManagementException.class, + () -> PlatformValidationLiveTests.deleteWithRetry(() -> { + attempts.incrementAndGet(); + throw error; + }, () -> { + }, () -> Assertions.fail("Unexpected retry.")))); + Assertions.assertEquals(1, attempts.get()); + } + } + + @Test + public void parentDeletionRetainsFailureAfterBoundedRetries() { + AtomicInteger attempts = new AtomicInteger(); + ManagementException error = deletionError(409, "CannotDeleteResource"); + Assertions.assertSame(error, + Assertions.assertThrows(ManagementException.class, () -> PlatformValidationLiveTests.deleteWithRetry(() -> { + attempts.incrementAndGet(); + throw error; + }, () -> { + }, () -> { + }))); + Assertions.assertEquals(30, attempts.get()); + } + + private static ManagementException deletionError(int status, String code) { + return new ManagementException("Test deletion response", + new MockHttpResponse(new HttpRequest(HttpMethod.DELETE, "https://management.azure.com/"), status), + new ManagementError(code, "Test deletion error")); + } + + @Test + public void regionalArmEndpointPreservesPublicCloudAuthentication() { + AzureEnvironment environment + = PlatformValidationLiveTests.testEnvironment("https://eastus2euap.management.azure.com"); + Assertions.assertEquals("https://eastus2euap.management.azure.com", environment.getResourceManagerEndpoint()); + Assertions.assertEquals(AzureEnvironment.AZURE.getActiveDirectoryEndpoint(), + environment.getActiveDirectoryEndpoint()); + Assertions.assertEquals(AzureEnvironment.AZURE.getManagementEndpoint(), environment.getManagementEndpoint()); + Assertions.assertEquals("https://management.azure.com/", + PlatformValidationLiveTests.testEnvironment(null).getResourceManagerEndpoint()); + Assertions.assertThrows(AssertionError.class, + () -> PlatformValidationLiveTests.testEnvironment("http://management.azure.com")); + Assertions.assertThrows(AssertionError.class, + () -> PlatformValidationLiveTests.testEnvironment("https://management.azure.com/?sig=example")); + } + + @Test + public void missingResourceRequires404() { + PlatformValidationLiveTests.assertMissing(() -> { + throw responseError(404); + }); + Assertions.assertThrows(AssertionError.class, () -> PlatformValidationLiveTests.assertMissing(() -> { + throw responseError(403); + })); + Assertions.assertThrows(AssertionError.class, () -> PlatformValidationLiveTests.assertMissing(() -> { + // A successful GET must block deletion of the parent. + })); + } + + @Test + public void failedOperationIsNotSuccess() { + SyncPoller poller = SyncPoller.createPoller(Duration.ofMillis(1), + context -> new PollResponse<>(LongRunningOperationStatus.FAILED, "failed"), + context -> new PollResponse<>(LongRunningOperationStatus.FAILED, "failed"), + (context, response) -> "cancelled", context -> "not-success"); + Assertions.assertThrows(AssertionError.class, () -> PlatformValidationLiveTests.complete(poller)); + } + + @Test + public void successfulOperationReturnsFinalResult() { + SyncPoller poller = SyncPoller.createPoller(Duration.ofMillis(1), + context -> new PollResponse<>(LongRunningOperationStatus.SUCCESSFULLY_COMPLETED, "done"), + context -> new PollResponse<>(LongRunningOperationStatus.SUCCESSFULLY_COMPLETED, "done"), + (context, response) -> "cancelled", context -> "result"); + Assertions.assertEquals("result", PlatformValidationLiveTests.complete(poller)); + } + + @Test + public void planConfigurationEscapesImageAndSelectsBootTest() { + String json + = PlatformValidationLiveTests.planConfiguration("jplan123", "https://example.invalid/image.vhd?a=\"b\""); + Map document = BinaryData.fromString(json).toObject(Map.class); + Assertions.assertEquals("ExecutionPlan", document.get("kind")); + Assertions.assertEquals("microsoft.validate/executionPlan.v0", document.get("apiVersion")); + Assertions.assertTrue(json.contains("compute-vmimagevalidation-vm-boot-test")); + Assertions.assertTrue(json.contains("sourceVhdUri")); + Assertions.assertTrue(json.contains("\\\"b\\\"")); + } + + @Test + public void recordingSanitizesImageInsideEmbeddedPlanJson() { + String source = "https://private.example.invalid/image.vhd?se=2026-12-01T00%3A00%3A00Z&sig=not-a-secret"; + String configuration = PlatformValidationLiveTests.planConfiguration("jplan123", source); + String body = BinaryData.fromObject(java.util.Collections.singletonMap("planConfigurationJson", configuration)) + .toString(); + String sanitized + = body.replaceAll(PlatformValidationLiveTests.SIGNED_URL_REGEX, PlatformValidationLiveTests.PLAYBACK_IMAGE); + Map document = BinaryData.fromString(sanitized).toObject(Map.class); + Assertions.assertEquals( + PlatformValidationLiveTests.planConfiguration("jplan123", PlatformValidationLiveTests.PLAYBACK_IMAGE), + document.get("planConfigurationJson")); + Assertions.assertFalse(sanitized.contains("private.example.invalid")); + Assertions.assertFalse(sanitized.contains("sig=")); + } + + @Test + public void recordingSanitizesSignedResultLinksWithoutChangingResourceIds() { + String body = "{\"id\":\"/subscriptions/example/resourceGroups/group\"," + + "\"link\":\"https://private.example.invalid/result?sig=not-a-secret\"}"; + String sanitized + = body.replaceAll(PlatformValidationLiveTests.SIGNED_URL_REGEX, PlatformValidationLiveTests.PLAYBACK_IMAGE); + Map document = BinaryData.fromString(sanitized).toObject(Map.class); + Assertions.assertEquals("/subscriptions/example/resourceGroups/group", document.get("id")); + Assertions.assertEquals(PlatformValidationLiveTests.PLAYBACK_IMAGE, document.get("link")); + } + + @Test + public void recordingNormalizesUnsignedImageForPlayback() { + String configuration + = PlatformValidationLiveTests.planConfiguration("jplan123", "https://private.example.invalid/image.vhd"); + Assertions.assertEquals( + PlatformValidationLiveTests.planConfiguration("jplan123", PlatformValidationLiveTests.PLAYBACK_IMAGE), + configuration.replaceAll(PlatformValidationLiveTests.IMAGE_URL_REGEX, + PlatformValidationLiveTests.PLAYBACK_IMAGE)); + } + + @Test + public void proxySanitizerRequestPreservesRegexEscapes() throws java.net.MalformedURLException { + HttpRequest request = TestProxyUtils.createAddSanitizersRequest( + java.util.Collections.singletonList(new TestProxySanitizer( + PlatformValidationLiveTests.proxyRegex(PlatformValidationLiveTests.SIGNED_URL_REGEX), + PlatformValidationLiveTests.PLAYBACK_IMAGE, TestProxySanitizerType.BODY_REGEX)), + new java.net.URL("http://localhost:5000")); + java.util.List sanitizers = request.getBodyAsBinaryData().toObject(java.util.List.class); + Map body = (Map) ((Map) sanitizers.get(0)).get("Body"); + Assertions.assertEquals(PlatformValidationLiveTests.SIGNED_URL_REGEX, body.get("regex")); + } + + private static HttpResponseException responseError(int status) { + return new HttpResponseException("Test response", + new MockHttpResponse(new HttpRequest(HttpMethod.GET, "https://management.azure.com/"), status)); + } + + @Test + public void imageExpiryCheckedBeforeCreatingResources() { + OffsetDateTime now = OffsetDateTime.parse("2026-10-08T00:00:00Z"); + PlatformValidationLiveTests + .validateImageSource("https://example.invalid/image.vhd?se=2026-10-10T00%3A00%3A00Z&sig=not-a-secret", now); + Assertions.assertThrows(AssertionError.class, () -> PlatformValidationLiveTests + .validateImageSource("https://example.invalid/image.vhd?se=2026-10-09T01%3A00%3A00Z", now)); + Assertions.assertThrows(AssertionError.class, () -> PlatformValidationLiveTests + .validateImageSource("https://example.invalid/image.vhd?se=2026-10-08T23%3A00%3A00Z", now)); + Assertions.assertThrows(AssertionError.class, + () -> PlatformValidationLiveTests.validateImageSource("http://example.invalid/image.vhd", now)); + } + + @Test + public void operationStatusTargetDropsSignedQuery() { + Assertions.assertArrayEquals(new String[] { "eastus", "operation123" }, + PlatformValidationLiveTests.operationStatusTarget( + "https://management.azure.com/subscriptions/example/providers/Microsoft.PlatformValidation" + + "/locations/eastus/operationStatuses/operation123?sig=not-a-secret")); + Assertions.assertNull(PlatformValidationLiveTests.operationStatusTarget(null)); + Assertions.assertNull(PlatformValidationLiveTests.operationStatusTarget("https://management.azure.com/other")); + } +} diff --git a/sdk/platformvalidation/azure-resourcemanager-platformvalidation/src/test/java/com/azure/resourcemanager/platformvalidation/PlatformValidationLiveTests.java b/sdk/platformvalidation/azure-resourcemanager-platformvalidation/src/test/java/com/azure/resourcemanager/platformvalidation/PlatformValidationLiveTests.java new file mode 100644 index 0000000000000..3873c7a6a0a1c --- /dev/null +++ b/sdk/platformvalidation/azure-resourcemanager-platformvalidation/src/test/java/com/azure/resourcemanager/platformvalidation/PlatformValidationLiveTests.java @@ -0,0 +1,646 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.resourcemanager.platformvalidation; + +import com.azure.core.credential.TokenCredential; +import com.azure.core.exception.HttpResponseException; +import com.azure.core.http.HttpClient; +import com.azure.core.http.HttpMethod; +import com.azure.core.http.HttpPipeline; +import com.azure.core.http.policy.HttpLogDetailLevel; +import com.azure.core.http.policy.HttpLogOptions; +import com.azure.core.http.policy.HttpPipelinePolicy; +import com.azure.core.http.policy.RetryPolicy; +import com.azure.core.management.AzureEnvironment; +import com.azure.core.management.exception.ManagementException; +import com.azure.core.management.profile.AzureProfile; +import com.azure.core.test.models.TestProxySanitizer; +import com.azure.core.test.models.TestProxySanitizerType; +import com.azure.core.util.BinaryData; +import com.azure.core.util.Configuration; +import com.azure.core.util.polling.LongRunningOperationStatus; +import com.azure.core.util.polling.SyncPoller; +import com.azure.resourcemanager.platformvalidation.fluent.PlatformValidationManagementClient; +import com.azure.resourcemanager.platformvalidation.fluent.models.CloudValidationInner; +import com.azure.resourcemanager.platformvalidation.fluent.models.ExecutionPlanRunInner; +import com.azure.resourcemanager.platformvalidation.fluent.models.ValidationExecutionPlanInner; +import com.azure.resourcemanager.platformvalidation.fluent.models.ValidationTestRunInner; +import com.azure.resourcemanager.platformvalidation.implementation.PlatformValidationManagementClientBuilder; +import com.azure.resourcemanager.platformvalidation.models.CloudValidationProperties; +import com.azure.resourcemanager.platformvalidation.models.CloudValidationUpdate; +import com.azure.resourcemanager.platformvalidation.models.CloudValidationUpdateProperties; +import com.azure.resourcemanager.platformvalidation.models.ExecutionPlanRunProperties; +import com.azure.resourcemanager.platformvalidation.models.TestRunSummary; +import com.azure.resourcemanager.platformvalidation.models.ValidationExecutionPlanProperties; +import com.azure.resourcemanager.platformvalidation.models.ValidationExecutionPlanUpdate; +import com.azure.resourcemanager.platformvalidation.models.ValidationExecutionPlanUpdateProperties; +import com.azure.resourcemanager.resources.ResourceManager; +import com.azure.resourcemanager.resources.fluentcore.policy.ProviderRegistrationPolicy; +import com.azure.resourcemanager.resources.fluentcore.utils.HttpPipelineProvider; +import com.azure.resourcemanager.resources.fluentcore.utils.ResourceManagerUtils; +import com.azure.resourcemanager.test.ResourceManagerTestProxyTestBase; +import com.azure.resourcemanager.test.policy.HttpDebugLoggingPolicy; +import com.azure.resourcemanager.test.utils.TestDelayProvider; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; + +import java.net.URI; +import java.net.URLDecoder; +import java.io.UnsupportedEncodingException; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +public class PlatformValidationLiveTests extends ResourceManagerTestProxyTestBase { + private static final Duration OPERATION_TIMEOUT = Duration.ofMinutes(10); + private static final String BOOT_TEST = "compute-vmimagevalidation-vm-boot-test"; + private static final String TEST_ID = "/providers/Microsoft.PlatformValidation/validationTests/" + BOOT_TEST; + private static final Configuration CONFIG = Configuration.getGlobalConfiguration(); + static final String PLAYBACK_IMAGE = "https://example.invalid/image.vhd"; + static final String IMAGE_URL_REGEX = "https://[^\\s\"\\\\<>]+"; + static final String SIGNED_URL_REGEX = "https://[^\\s\"\\\\<>]+\\?[^\\s\"\\\\<>]+"; + private static final String SUBSCRIPTION_REGEX = "(?i)(?<=/subscriptions/)[^/\"\\\\?\\s]+"; + private final ProviderRegistrationPolicy registrationPolicy = new ProviderRegistrationPolicy(); + private final AtomicReference operationTarget = new AtomicReference<>(); + private PlatformValidationManagementClient client; + private ResourceManager resources; + private String group; + private String location; + private String subscription; + private boolean createManagedGroup; + + public PlatformValidationLiveTests() { + addSanitizers( + // Signed URLs can also appear in service errors and execution results, outside the embedded plan JSON. + new TestProxySanitizer(proxyRegex(SIGNED_URL_REGEX), PLAYBACK_IMAGE, TestProxySanitizerType.BODY_REGEX), + new TestProxySanitizer("$..planConfigurationJson", proxyRegex(IMAGE_URL_REGEX), PLAYBACK_IMAGE, + TestProxySanitizerType.BODY_KEY), + new TestProxySanitizer("$..planConfigurationSnapshot", proxyRegex(IMAGE_URL_REGEX), PLAYBACK_IMAGE, + TestProxySanitizerType.BODY_KEY), + new TestProxySanitizer("$..inputsJson", proxyRegex(IMAGE_URL_REGEX), PLAYBACK_IMAGE, + TestProxySanitizerType.BODY_KEY), + new TestProxySanitizer("[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+[.][A-Za-z]{2,}", REDACTED_VALUE, + TestProxySanitizerType.BODY_REGEX), + new TestProxySanitizer(proxyRegex(SUBSCRIPTION_REGEX), "00000000-0000-0000-0000-000000000000", + TestProxySanitizerType.URL), + new TestProxySanitizer(proxyRegex(SUBSCRIPTION_REGEX), "00000000-0000-0000-0000-000000000000", + TestProxySanitizerType.BODY_REGEX), + new TestProxySanitizer("x-ms-operation-identifier", null, REDACTED_VALUE, TestProxySanitizerType.HEADER), + new TestProxySanitizer("$..tenantId", null, "00000000-0000-0000-0000-000000000000", + TestProxySanitizerType.BODY_KEY), + new TestProxySanitizer("$..principalId", null, "00000000-0000-0000-0000-000000000000", + TestProxySanitizerType.BODY_KEY), + new TestProxySanitizer("$..createdBy", null, REDACTED_VALUE, TestProxySanitizerType.BODY_KEY), + new TestProxySanitizer("$..lastModifiedBy", null, REDACTED_VALUE, TestProxySanitizerType.BODY_KEY)); + for (String header : Arrays.asList("Azure-AsyncOperation", "Operation-Location", "Location")) { + addSanitizers(new TestProxySanitizer(header, proxyRegex(SUBSCRIPTION_REGEX), + "00000000-0000-0000-0000-000000000000", TestProxySanitizerType.HEADER)); + } + } + + static String proxyRegex(String regex) { + // This framework version interpolates regex strings directly into the proxy's JSON request. + return regex.replace("\\", "\\\\").replace("\"", "\\\""); + } + + @Override + protected HttpPipeline buildHttpPipeline(TokenCredential credential, AzureProfile profile, + HttpLogOptions httpLogOptions, List policies, HttpClient httpClient) { + List testPolicies = new ArrayList<>(policies); + // The framework's default body logger would expose the image SAS before recording sanitization. + testPolicies.removeIf(HttpDebugLoggingPolicy.class::isInstance); + testPolicies.add(registrationPolicy); + testPolicies.add((context, next) -> next.process().map(response -> { + if (context.getHttpRequest().getHttpMethod() == HttpMethod.PATCH) { + String target = response.getHeaderValue("Azure-AsyncOperation"); + if (target == null) { + target = response.getHeaderValue("Operation-Location"); + } + operationTarget.set(operationStatusTarget(target)); + } + return response; + })); + return HttpPipelineProvider.buildHttpPipeline(credential, profile, null, + new HttpLogOptions().setLogLevel(HttpLogDetailLevel.NONE), null, + new RetryPolicy("Retry-After", ChronoUnit.SECONDS), testPolicies, httpClient); + } + + @Override + protected void initializeClients(HttpPipeline httpPipeline, AzureProfile profile) { + AzureProfile serviceProfile = isPlaybackMode() + ? profile + : new AzureProfile(profile.getTenantId(), profile.getSubscriptionId(), + testEnvironment(CONFIG.get("PLATFORMVALIDATION_ARM_ENDPOINT"))); + subscription = serviceProfile.getSubscriptionId(); + group + = testResourceNamer.recordValueFromConfig(isPlaybackMode() ? null : required("AZURE_RESOURCE_GROUP_NAME")); + location + = testResourceNamer.recordValueFromConfig(CONFIG.get("PLATFORMVALIDATION_LOCATION", "southcentralusstg")); + createManagedGroup = Boolean.parseBoolean( + testResourceNamer.recordValueFromConfig(CONFIG.get("PLATFORMVALIDATION_CREATE_MANAGED_GROUP", "false"))); + ResourceManagerUtils.InternalRuntimeContext.setDelayProvider(new TestDelayProvider(!isPlaybackMode())); + resources = ResourceManager.authenticate(httpPipeline, serviceProfile).withSubscription(subscription); + registrationPolicy.setProviders(resources.providers()); + client = new PlatformValidationManagementClientBuilder().pipeline(httpPipeline) + .endpoint(serviceProfile.getEnvironment().getResourceManagerEndpoint()) + .subscriptionId(subscription) + .defaultPollInterval(isPlaybackMode() ? Duration.ofMillis(1) : Duration.ofSeconds(5)) + .buildClient(); + Assertions.assertTrue(resources.resourceGroups().contain(group), "Provide a dedicated test resource group."); + } + + @Override + protected void cleanUpResources() { + // Scenarios delete only their own children on success and retain failure evidence, never the supplied group. + } + + @Test + public void cloudValidationLifecycle() { + safeServiceCall(() -> { + String name = name("jcv"); + CloudValidationInner created = createCloudValidation(name); + assertCloudValidation(created, name, "Java SDK live test"); + assertCloudValidation(client.getCloudValidations().getByResourceGroup(group, name), name, + "Java SDK live test"); + Assertions.assertEquals(1, + client.getCloudValidations() + .listByResourceGroup(group) + .stream() + .filter(item -> created.id().equalsIgnoreCase(item.id())) + .count()); + Assertions.assertEquals(1, + client.getCloudValidations() + .list() + .stream() + .filter(item -> created.id().equalsIgnoreCase(item.id())) + .count()); + CloudValidationInner patched = complete(client.getCloudValidations() + .beginUpdate(group, name, new CloudValidationUpdate().withTags(tags(name, "patched")) + .withProperties(new CloudValidationUpdateProperties().withDescription("Java SDK PATCH")))); + assertCloudValidation(patched, name, "Java SDK PATCH"); + Assertions.assertEquals(tags(name, "patched"), patched.tags()); + assertOperationSucceeded(); + assertCloudValidation(client.getCloudValidations().getByResourceGroup(group, name), name, "Java SDK PATCH"); + CloudValidationInner replaced = complete(client.getCloudValidations() + .beginCreateOrUpdate(group, name, + new CloudValidationInner().withLocation(location) + .withTags(tags(name, "put")) + .withProperties(new CloudValidationProperties().withDescription("Java SDK PUT")))); + assertCloudValidation(replaced, name, "Java SDK PUT"); + Assertions.assertEquals(tags(name, "put"), replaced.tags()); + assertCloudValidation(client.getCloudValidations().getByResourceGroup(group, name), name, "Java SDK PUT"); + deleteCloudValidation(name); + }); + } + + @Test + public void executionPlanLifecycle() { + String source = sourceImage(); + safeServiceCall(() -> exerciseExecution(source, false)); + } + + @Test + public void bootExecutionLifecycle() { + Assumptions.assumeTrue(isPlaybackMode() || CONFIG.get("PLATFORMVALIDATION_RUN_EXECUTION", false), + "Boot execution requires explicit opt-in and an approved image fixture."); + String source = isPlaybackMode() ? PLAYBACK_IMAGE : required("PLATFORMVALIDATION_SOURCE_VHD_URI"); + if (!isPlaybackMode()) { + validateImageSource(source, OffsetDateTime.now()); + } + safeServiceCall(() -> exerciseExecution(source, true)); + } + + @Test + public void operationsList() { + safeServiceCall(() -> Assertions.assertTrue(client.getOperations() + .list() + .stream() + .anyMatch(operation -> operation.name() != null + && operation.name().startsWith("Microsoft.PlatformValidation/")))); + } + + @Test + public void validationTestsList() { + safeServiceCall(() -> Assertions.assertTrue( + client.getValidationTests().list().stream().anyMatch(test -> BOOT_TEST.equals(test.name())), + "Boot test should be discoverable.")); + } + + @Test + public void validationTestGet() { + safeServiceCall(() -> Assertions.assertEquals(BOOT_TEST, client.getValidationTests().get(BOOT_TEST).name())); + } + + @Test + public void validationTestVersions() { + safeServiceCall(() -> { + List versions = client.getValidationTestVersions() + .list(BOOT_TEST) + .stream() + .map(version -> version.name()) + .collect(Collectors.toList()); + Assertions.assertFalse(versions.isEmpty(), "No test version was discoverable; do not guess a version."); + for (String version : versions) { + Assertions.assertEquals(version, client.getValidationTestVersions().get(BOOT_TEST, version).name()); + } + }); + } + + @Test + public void validationTestCategories() { + safeServiceCall(() -> { + List categories = client.getValidationTestCategories() + .list() + .stream() + .map(category -> category.name()) + .collect(Collectors.toList()); + Assertions.assertFalse(categories.isEmpty(), "No category was discoverable; do not guess a category."); + for (String category : categories) { + Assertions.assertEquals(category, client.getValidationTestCategories().get(category).name()); + } + }); + } + + private void exerciseExecution(String source, boolean execute) { + String cv = name("jcv"); + String planName = name("jplan"); + String runName = name("jrun"); + String planId = cvId(cv) + "/validationExecutionPlans/" + planName; + String runId = planId + "/executionPlanRuns/" + runName; + String description = "Java SDK execution " + runName; + String configuration = planConfiguration(planName, source); + createCloudValidation(cv); + if (createManagedGroup) { + Assertions.assertFalse(resources.resourceGroups().contain(cv + "-mrg"), + "Refusing to adopt a managed resource group created by another owner."); + resources.resourceGroups().define(cv + "-mrg").withRegion(location).withTags(tags(cv, "manual")).create(); + } + Assertions.assertTrue(resources.resourceGroups().contain(cv + "-mrg"), + "Managed group is absent. Retaining CV; no automatic manual provisioning or RBAC fallback."); + assertMissing(() -> client.getValidationExecutionPlans().get(group, cv, planName)); + System.out.println("JAVA_TEST_PLAN " + planId); + ValidationExecutionPlanInner plan + = complete( + client.getValidationExecutionPlans() + .beginCreateOrUpdate(group, cv, planName, new ValidationExecutionPlanInner().withLocation(location) + .withTags(tags(planName, "created")) + .withProperties(new ValidationExecutionPlanProperties().withDescription("Java SDK boot plan") + .withPlanConfigurationJson(configuration)))); + assertPlan(plan, planId, configuration, "Java SDK boot plan"); + assertPlan(client.getValidationExecutionPlans().get(group, cv, planName), planId, configuration, + "Java SDK boot plan"); + Assertions.assertEquals(1, + client.getValidationExecutionPlans() + .listByResourceGroup(group, cv) + .stream() + .filter(item -> planId.equalsIgnoreCase(item.id())) + .count()); + ValidationExecutionPlanInner updated = complete(client.getValidationExecutionPlans() + .beginUpdate(group, cv, planName, new ValidationExecutionPlanUpdate().withTags(tags(planName, "patched")) + .withProperties(new ValidationExecutionPlanUpdateProperties().withDescription("Java SDK plan PATCH")))); + assertPlan(updated, planId, configuration, "Java SDK plan PATCH"); + Assertions.assertEquals(tags(planName, "patched"), updated.tags()); + assertOperationSucceeded(); + assertPlan(client.getValidationExecutionPlans().get(group, cv, planName), planId, configuration, + "Java SDK plan PATCH"); + if (execute) { + assertMissing(() -> client.getExecutionPlanRuns().get(group, cv, planName, runName)); + System.out.println("JAVA_TEST_RUN " + runId); + complete(client.getExecutionPlanRuns() + .beginCreateOrUpdate(group, cv, planName, runName, new ExecutionPlanRunInner() + .withProperties(new ExecutionPlanRunProperties().withDescription(description)))); + ExecutionPlanRunInner run = waitForWorkload(cv, planName, runName); + assertRun(run, runId, description); + List runs = client.getExecutionPlanRuns() + .listByExecutionPlan(group, cv, planName) + .stream() + .collect(Collectors.toList()); + Assertions.assertEquals(1, runs.size()); + assertRun(runs.get(0), runId, description); + List tests = client.getValidationTestRuns() + .listByExecutionPlanRun(group, cv, planName, runName) + .stream() + .collect(Collectors.toList()); + Assertions.assertEquals(1, tests.size()); + for (ValidationTestRunInner test : tests) { + assertTestRun(test, runId); + assertTestRun(client.getValidationTestRuns().get(group, cv, planName, runName, test.name()), runId); + } + System.out.println("JAVA_TEST_WORKLOAD_PASSED " + runId); + complete(client.getExecutionPlanRuns().beginDelete(group, cv, planName, runName)); + assertMissing(() -> client.getExecutionPlanRuns().get(group, cv, planName, runName)); + // A missing parent does not establish child cleanup. Preserve evidence if any child survives. + for (ValidationTestRunInner test : tests) { + assertMissing(() -> client.getValidationTestRuns().get(group, cv, planName, runName, test.name())); + } + } + Assertions.assertFalse( + client.getExecutionPlanRuns().listByExecutionPlan(group, cv, planName).iterator().hasNext(), + "Runs remain; refusing parent deletion."); + plan = client.getValidationExecutionPlans().get(group, cv, planName); + Assertions.assertEquals(planName, plan.tags().get("sdk-test")); + assertPlan(plan, planId, configuration, "Java SDK plan PATCH"); + complete(client.getValidationExecutionPlans().beginDelete(group, cv, planName)); + assertMissing(() -> client.getValidationExecutionPlans().get(group, cv, planName)); + deleteCloudValidation(cv); + } + + private CloudValidationInner createCloudValidation(String name) { + assertMissing(() -> client.getCloudValidations().getByResourceGroup(group, name)); + Assertions.assertFalse(resources.resourceGroups().contain(name + "-mrg"), + "Refusing to reuse an existing managed group."); + System.out.println("JAVA_TEST_CV " + cvId(name)); + CloudValidationInner result = complete(client.getCloudValidations() + .beginCreateOrUpdate(group, name, + new CloudValidationInner().withLocation(location) + .withTags(tags(name, "created")) + .withProperties(new CloudValidationProperties().withDescription("Java SDK live test")))); + assertCloudValidation(result, name, "Java SDK live test"); + return result; + } + + private void deleteCloudValidation(String name) { + CloudValidationInner current = client.getCloudValidations().getByResourceGroup(group, name); + Assertions.assertEquals(cvId(name).toLowerCase(java.util.Locale.ROOT), + current.id().toLowerCase(java.util.Locale.ROOT)); + Assertions.assertEquals(name, current.tags().get("sdk-test")); + Assertions.assertEquals("Succeeded", current.properties().provisioningState().toString()); + Assertions.assertFalse( + client.getValidationExecutionPlans().listByResourceGroup(group, name).iterator().hasNext(), + "Plans remain; refusing parent deletion."); + if (createManagedGroup && resources.resourceGroups().contain(name + "-mrg")) { + Assertions.assertEquals(name, resources.resourceGroups().getByName(name + "-mrg").tags().get("sdk-test")); + Assertions.assertFalse(resources.genericResources().listByResourceGroup(name + "-mrg").iterator().hasNext(), + "Managed group is not empty; retaining it."); + resources.resourceGroups().deleteByName(name + "-mrg"); + waitForManagedGroupDeletion(name + "-mrg"); + } + deleteWithRetry(() -> complete(client.getCloudValidations().beginDelete(group, name)), + () -> Assertions.assertFalse( + client.getValidationExecutionPlans().listByResourceGroup(group, name).iterator().hasNext(), + "Plans remain; refusing parent deletion."), + () -> sleepIfRunningAgainstService(10000)); + assertMissing(() -> client.getCloudValidations().getByResourceGroup(group, name)); + waitForManagedGroupDeletion(name + "-mrg"); + System.out.println("JAVA_TEST_CV_DELETED " + cvId(name)); + } + + static void deleteWithRetry(Runnable delete, Runnable verifyNoPlans, Runnable delay) { + for (int attempt = 0;; attempt++) { + verifyNoPlans.run(); + try { + delete.run(); + return; + } catch (ManagementException error) { + if (attempt == 29 + || error.getResponse() == null + || error.getResponse().getStatusCode() != 409 + || error.getValue() == null + || !"CannotDeleteResource".equals(error.getValue().getCode())) { + throw error; + } + // Child deletion can precede the service's parent-deletion eligibility check. + delay.run(); + } + } + } + + private ExecutionPlanRunInner waitForWorkload(String cv, String plan, String run) { + long deadline = System.nanoTime() + Duration.ofMinutes(30).toNanos(); + while (System.nanoTime() < deadline) { + ExecutionPlanRunInner result = client.getExecutionPlanRuns().get(group, cv, plan, run); + String status = String.valueOf(result.properties().status()); + if (Arrays.asList("Succeeded", "Completed", "Failed", "Canceled", "Cancelled", "TimedOut") + .contains(status)) { + return result; + } + Assertions.assertFalse( + Arrays.asList("Failed", "Canceled").contains(String.valueOf(result.properties().provisioningState())), + "Run provisioning failed."); + sleepIfRunningAgainstService(10000); + } + throw new AssertionError("Workload did not finish in 30 minutes; all owned resources retained."); + } + + private void waitForManagedGroupDeletion(String name) { + long deadline = System.nanoTime() + Duration.ofMinutes(2).toNanos(); + int absentChecks = 0; + while (System.nanoTime() < deadline) { + absentChecks = resources.resourceGroups().contain(name) ? 0 : absentChecks + 1; + if (absentChecks == 2) { + return; + } + sleepIfRunningAgainstService(5000); + } + throw new AssertionError("Managed group deletion was not confirmed; resources retained."); + } + + private void assertCloudValidation(CloudValidationInner value, String name, String description) { + Assertions.assertTrue(cvId(name).equalsIgnoreCase(value.id())); + Assertions.assertEquals(name, value.name()); + Assertions.assertEquals(name, value.tags().get("sdk-test")); + Assertions.assertEquals(location, value.location()); + Assertions.assertEquals("Succeeded", value.properties().provisioningState().toString()); + Assertions.assertEquals(description, value.properties().description()); + } + + private static void assertPlan(ValidationExecutionPlanInner plan, String id, String configuration, + String description) { + Assertions.assertTrue(id.equalsIgnoreCase(plan.id())); + Assertions.assertEquals("Succeeded", plan.properties().provisioningState().toString()); + Assertions.assertEquals(description, plan.properties().description()); + // Never include signed image URLs in assertion diagnostics. + Assertions.assertTrue( + BinaryData.fromString(configuration) + .toObject(Map.class) + .equals(BinaryData.fromString(plan.properties().planConfigurationJson()).toObject(Map.class)), + "Plan configuration changed."); + } + + private static void assertRun(ExecutionPlanRunInner run, String id, String description) { + Assertions.assertTrue(id.equalsIgnoreCase(run.id())); + Assertions.assertEquals(description, run.properties().description()); + Assertions.assertEquals("Succeeded", run.properties().provisioningState().toString()); + Assertions.assertEquals("Succeeded", run.properties().status().toString()); + Assertions.assertTrue(run.properties().error() == null, "Run returned a service error."); + TestRunSummary summary = run.properties().testRunSummary(); + Assertions.assertNotNull(summary); + Assertions.assertEquals("Passed", summary.overallResult().toString()); + Assertions.assertEquals(Integer.valueOf(1), summary.totalTests()); + Assertions.assertEquals(Integer.valueOf(1), summary.passedTests()); + Assertions.assertEquals(Integer.valueOf(0), summary.failedTests()); + Assertions.assertEquals(Integer.valueOf(0), summary.skippedTests()); + Assertions.assertNotNull(run.properties().startedAt()); + Assertions.assertNotNull(run.properties().completedAt()); + Assertions.assertFalse(run.properties().completedAt().isBefore(run.properties().startedAt())); + } + + private static void assertTestRun(ValidationTestRunInner test, String runId) { + Assertions.assertTrue((runId + "/validationTestRuns/" + test.name()).equalsIgnoreCase(test.id())); + Assertions.assertEquals("Completed", test.properties().status().toString()); + Assertions.assertEquals("Succeeded", test.properties().provisioningState().toString()); + Assertions.assertEquals(TEST_ID, test.properties().testId()); + Assertions.assertTrue(test.properties().error() == null, "Test run returned a service error."); + Assertions.assertNotNull(test.properties().passDetails()); + Assertions.assertFalse(test.properties().passDetails().isEmpty()); + Assertions.assertTrue( + test.properties().failureDetails() == null || test.properties().failureDetails().isEmpty(), + "Boot test contains failure details."); + } + + static R complete(SyncPoller poller) { + Assertions.assertEquals(LongRunningOperationStatus.SUCCESSFULLY_COMPLETED, + poller.waitForCompletion(OPERATION_TIMEOUT).getStatus(), "LRO failed; resources retained."); + return poller.getFinalResult(); + } + + static void assertMissing(Executable operation) { + HttpResponseException error = Assertions.assertThrows(HttpResponseException.class, operation, + "Expected HTTP 404; resource still exists. Retaining parent resources."); + Assertions.assertNotNull(error.getResponse()); + Assertions.assertEquals(404, error.getResponse().getStatusCode()); + } + + private String cvId(String name) { + return "/subscriptions/" + subscription + "/resourceGroups/" + group + + "/providers/Microsoft.PlatformValidation/cloudValidations/" + name; + } + + private void assertOperationSucceeded() { + String[] target = operationTarget.get(); + Assertions.assertNotNull(target, "PATCH must expose a supported operation-status route."); + Assertions.assertEquals("Succeeded", client.getOperationStatus().get(target[0], target[1]).status()); + } + + static String[] operationStatusTarget(String url) { + if (url == null) { + return null; + } + Matcher matcher = Pattern.compile("/locations/([^/]+)/operationStatuses/([^/]+)$", Pattern.CASE_INSENSITIVE) + .matcher(URI.create(url).getPath()); + return matcher.find() ? new String[] { matcher.group(1), matcher.group(2) } : null; + } + + static AzureEnvironment testEnvironment(String endpoint) { + if (endpoint == null || endpoint.isEmpty()) { + return AzureEnvironment.AZURE; + } + URI uri = URI.create(endpoint); + Assertions.assertTrue("https".equals(uri.getScheme()) + && uri.getHost() != null + && uri.getRawUserInfo() == null + && uri.getRawQuery() == null + && uri.getRawFragment() == null + && (uri.getPath().isEmpty() || "/".equals(uri.getPath())), "ARM endpoint must be an HTTPS origin."); + Map endpoints = new LinkedHashMap<>(AzureEnvironment.AZURE.getEndpoints()); + endpoints.put(AzureEnvironment.Endpoint.RESOURCE_MANAGER.identifier(), endpoint); + return new AzureEnvironment(endpoints); + } + + private String name(String prefix) { + return generateRandomResourceName(prefix, prefix.length() + 16); + } + + private static Map tags(String owner, String phase) { + Map result = new LinkedHashMap<>(); + result.put("sdk-test", owner); + result.put("phase", phase); + return result; + } + + private static String required(String name) { + String value = CONFIG.get(name); + Assertions.assertTrue(value != null && !value.isEmpty(), name + " must be configured."); + return value; + } + + private String sourceImage() { + if (isPlaybackMode()) { + return PLAYBACK_IMAGE; + } + String source = CONFIG.get("PLATFORMVALIDATION_SOURCE_VHD_URI"); + Assumptions.assumeTrue(source != null && !source.isEmpty(), "An approved image fixture is required."); + validateImageSource(source, OffsetDateTime.now()); + return source; + } + + static void validateImageSource(String source, OffsetDateTime now) { + URI uri; + try { + uri = URI.create(source); + } catch (IllegalArgumentException error) { + throw new AssertionError("Image fixture is not a valid URI."); + } + Assertions.assertEquals("https", uri.getScheme(), "Image fixture must use HTTPS."); + Assertions.assertNotNull(uri.getHost(), "Image fixture must include a host."); + String query = uri.getRawQuery(); + if (query != null) { + for (String parameter : query.split("&")) { + if (parameter.startsWith("se=")) { + try { + OffsetDateTime expiry + = OffsetDateTime.parse(URLDecoder.decode(parameter.substring(3), "UTF-8")); + Assertions.assertTrue(expiry.isAfter(now.plusHours(25)), + "Image SAS must remain valid for more than 25 hours (service minimum: one day)."); + } catch (java.time.format.DateTimeParseException | UnsupportedEncodingException error) { + throw new AssertionError("Image SAS expiry cannot be parsed."); + } + } + } + } + } + + static String planConfiguration(String name, String source) { + Map image = new LinkedHashMap<>(); + image.put("osType", "Linux"); + image.put("vmGenerationType", "V1"); + image.put("architectureType", "X64"); + image.put("recommendedVMSizes", Collections.singletonList("Standard_D4s_v3")); + Map storage = new LinkedHashMap<>(); + storage.put("osDiskImage", Collections.singletonMap("sourceVhdUri", source)); + storage.put("dataDiskImages", Collections.emptyList()); + image.put("storageProfile", storage); + image.put("additionalProperties", Collections.emptyMap()); + Map step = new LinkedHashMap<>(); + step.put("name", "vm-boot-test"); + step.put("type", "test"); + step.put("testRef", TEST_ID); + Map document = new LinkedHashMap<>(); + document.put("apiVersion", "microsoft.validate/executionPlan.v0"); + document.put("kind", "ExecutionPlan"); + document.put("metadata", Collections.singletonMap("name", name)); + document.put("parameters", Collections.singletonMap("certificationPackageReference", image)); + document.put("authoring", Collections.singletonMap("steps", Collections.singletonList(step))); + return BinaryData.fromObject(document).toString(); + } + + private static void safeServiceCall(Runnable action) { + try { + action.run(); + } catch (HttpResponseException error) { + // Service messages may echo the embedded document and its image SAS. Do not attach the original cause. + int status = error.getResponse() == null ? 0 : error.getResponse().getStatusCode(); + String requestId + = error.getResponse() == null ? null : error.getResponse().getHeaderValue("x-ms-request-id"); + String path = error.getResponse() == null ? null : error.getResponse().getRequest().getUrl().getPath(); + String code = error instanceof ManagementException && ((ManagementException) error).getValue() != null + ? ((ManagementException) error).getValue().getCode() + : error.getClass().getSimpleName(); + throw new AssertionError("Service request failed: HTTP " + status + "; requestId=" + requestId + "; code=" + + code + "; path=" + path + ". Test-owned resources retained."); + } + } +}