diff --git a/sdk/platformvalidation/arm-platformvalidation/assets.json b/sdk/platformvalidation/arm-platformvalidation/assets.json new file mode 100644 index 000000000000..4ee38ac918cb --- /dev/null +++ b/sdk/platformvalidation/arm-platformvalidation/assets.json @@ -0,0 +1,6 @@ +{ + "AssetsRepo": "Azure/azure-sdk-assets", + "AssetsRepoPrefixPath": "js", + "TagPrefix": "js/platformvalidation/arm-platformvalidation", + "Tag": "js/platformvalidation/arm-platformvalidation_c4bdccbdfa" +} diff --git a/sdk/platformvalidation/arm-platformvalidation/sample.env b/sdk/platformvalidation/arm-platformvalidation/sample.env index 508439fc7d62..df7e0c516c29 100644 --- a/sdk/platformvalidation/arm-platformvalidation/sample.env +++ b/sdk/platformvalidation/arm-platformvalidation/sample.env @@ -1 +1,7 @@ -# Feel free to add your own environment variables. \ No newline at end of file +# Required for live/record mode only. Use an existing resource group and an approved HTTPS VHD. +AZURE_SUBSCRIPTION_ID= +AZURE_TENANT_ID= +PLATFORMVALIDATION_RESOURCE_GROUP= +PLATFORMVALIDATION_LOCATION= +PLATFORMVALIDATION_ARM_ENDPOINT=https://management.azure.com +PLATFORMVALIDATION_SOURCE_VHD_URI= \ No newline at end of file diff --git a/sdk/platformvalidation/arm-platformvalidation/test/public/lifecycle.spec.ts b/sdk/platformvalidation/arm-platformvalidation/test/public/lifecycle.spec.ts new file mode 100644 index 000000000000..4d1600f9a64c --- /dev/null +++ b/sdk/platformvalidation/arm-platformvalidation/test/public/lifecycle.spec.ts @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { RestError } from "@azure/core-rest-pipeline"; +import { describe, expect, it, vi } from "vitest"; +import { + completeMutation, + deleteIfPresent, + requireCredentialFreeVhd, + waitForTerminalTestRun, +} from "./utils/lifecycle.js"; +import { ownedCloudValidationSanitizers } from "./utils/recordedClient.js"; + +describe("PlatformValidation lifecycle safeguards", () => { + it.each([ + "https://example.invalid/image.vhd?sig=example-secret", + "https://user:example-secret@example.invalid/image.vhd", + "http://example.invalid/image.vhd", + "https://example.invalid/image.vhd#example-secret", + "invalid example-secret", + ])("rejects unsafe image input without echoing it: %s", (source) => { + expect(() => requireCredentialFreeVhd(source)).toThrow( + /^An approved credential-free HTTPS VHD URI is required$/, + ); + }); + + it("accepts a credential-free HTTPS VHD without rewriting it", () => { + const source = "https://example.invalid/images/approved.vhd"; + expect(requireCredentialFreeVhd(source)).toBe(source); + }); + + it("retains the mutation guard when a done poller throws a transport error", async () => { + const state = { inProgress: false }; + const error = new RestError("Polling unavailable", { statusCode: 503 }); + const poller = { isDone: true, pollUntilDone: vi.fn().mockRejectedValue(error) }; + await expect(completeMutation(state, poller)).rejects.toBe(error); + expect(state.inProgress).toBe(true); + }); + + it("clears the mutation guard only after successful polling", async () => { + const state = { inProgress: false }; + const poller = { + pollUntilDone: vi.fn(async () => { + expect(state.inProgress).toBe(true); + return "completed"; + }), + }; + await expect(completeMutation(state, poller)).resolves.toBe("completed"); + expect(state.inProgress).toBe(false); + }); + + it("continues parent cleanup when an attempted child is already absent", async () => { + const childDelete = vi.fn().mockRejectedValue(new RestError("Absent", { statusCode: 404 })); + const parentDelete = vi.fn().mockResolvedValue(undefined); + await deleteIfPresent(childDelete); + await deleteIfPresent(parentDelete); + expect(parentDelete).toHaveBeenCalledOnce(); + }); + + it("waits for child provisioning after the workload reports completion", async () => { + const state = { inProgress: false }; + const completed = { properties: { status: "Completed", provisioningState: "Succeeded" } }; + const read = vi + .fn() + .mockResolvedValueOnce({ properties: { status: "Completed", provisioningState: "Accepted" } }) + .mockResolvedValueOnce(completed); + const pause = vi.fn(async () => { + expect(state.inProgress).toBe(true); + }); + await expect(waitForTerminalTestRun(state, read, pause)).resolves.toBe(completed); + expect(read).toHaveBeenCalledTimes(2); + expect(pause).toHaveBeenCalledOnce(); + expect(state.inProgress).toBe(false); + }); + + it("retains resources if child provisioning never becomes terminal", async () => { + const state = { inProgress: false }; + const read = vi.fn().mockResolvedValue({ + properties: { status: "Completed", provisioningState: "Accepted" }, + }); + await expect(waitForTerminalTestRun(state, read, async () => {})).rejects.toThrow( + "Test result did not reach a terminal state; resources retained", + ); + expect(read).toHaveBeenCalledTimes(180); + expect(state.inProgress).toBe(true); + }); + + it("does not suppress other deletion errors", async () => { + const error = new RestError("Delete unavailable", { statusCode: 503 }); + await expect(deleteIfPresent(async () => Promise.reject(error))).rejects.toBe(error); + }); + + it("rejects an ownership exemption that is not a full cloud validation ID", () => { + expect(() => + ownedCloudValidationSanitizers( + "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-pv-js-owned", + ), + ).toThrow("An owned cloud validation resource ID is required"); + }); + + it("sanitizes another run with the same prefix or name outside the owned resource", () => { + const owned = + "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-pv-js-owned/providers/Microsoft.PlatformValidation/cloudValidations/jscvowned"; + const options = ownedCloudValidationSanitizers(owned); + const bodyPattern = options.bodySanitizers![0].target; + const pattern = new RegExp(bodyPattern.replace("(?i)", ""), "i"); + expect(pattern.test(owned)).toBe(false); + expect(pattern.test(`${owned}/validationExecutionPlans/plan`)).toBe(false); + expect(pattern.test(owned.replace("jscvowned", "jscvother"))).toBe(true); + expect(pattern.test(owned.replace("jscvowned", "jscvowned-another-run"))).toBe(true); + expect(pattern.test(owned.replace("rg-pv-js-owned", "rg-pv-js-other"))).toBe(true); + expect(pattern.test(owned.replaceAll("11111111", "22222222"))).toBe(true); + const sanitized = owned + .replace("11111111-1111-1111-1111-111111111111", "00000000-0000-0000-0000-000000000000") + .replace("rg-pv-js-owned", "rg-platformvalidation-test"); + expect(pattern.test(sanitized)).toBe(false); + + const groupPattern = new RegExp(options.bodySanitizers![1].target.replace("(?i)", ""), "i"); + expect(groupPattern.test(owned)).toBe(false); + expect(groupPattern.test(sanitized)).toBe(false); + expect(groupPattern.test(owned.replace("rg-pv-js-owned", "rg-pv-js-other"))).toBe(true); + expect(groupPattern.test(owned.replace("rg-pv-js-owned", "rg-pv-js-owned-extra"))).toBe(true); + }); +}); diff --git a/sdk/platformvalidation/arm-platformvalidation/test/public/platformValidation.spec.ts b/sdk/platformvalidation/arm-platformvalidation/test/public/platformValidation.spec.ts new file mode 100644 index 000000000000..8b28fbe028aa --- /dev/null +++ b/sdk/platformvalidation/arm-platformvalidation/test/public/platformValidation.spec.ts @@ -0,0 +1,421 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { Recorder } from "@azure-tools/test-recorder"; +import { assertEnvironmentVariable, isPlaybackMode } from "@azure-tools/test-recorder"; +import type { PollerLike, OperationState } from "@azure/core-lro"; +import { delay, randomUUID } from "@azure/core-util"; +import { isRestError } from "@azure/core-rest-pipeline"; +import { assert, expect, beforeEach, afterEach, it, describe } from "vitest"; +import type { PlatformValidationClient } from "../../src/index.js"; +import { + createClient, + createRecorder, + ownedCloudValidationSanitizers, +} from "./utils/recordedClient.js"; +import { + completeMutation, + deleteIfPresent, + requireCredentialFreeVhd, + waitForTerminalTestRun, +} from "./utils/lifecycle.js"; + +const bootTestName = "compute-vmimagevalidation-vm-boot-test"; +const bootTestId = `/providers/Microsoft.PlatformValidation/validationTests/${bootTestName}`; + +async function collect(items: AsyncIterable): Promise { + const result: T[] = []; + for await (const item of items) { + result.push(item); + } + return result; +} + +async function assertNotFound(action: () => Promise): Promise { + await expect(action()).rejects.toMatchObject({ statusCode: 404 }); +} + +describe("PlatformValidation", () => { + let recorder: Recorder; + let client: PlatformValidationClient; + + beforeEach(async (context) => { + recorder = await createRecorder(context); + client = createClient(recorder); + }); + + afterEach(async () => { + await recorder?.stop(); + }); + + it("lists provider operations", async () => { + const operations = await collect(client.operations.list()); + for (const resource of [ + "cloudValidations", + "cloudValidations/validationExecutionPlans", + "cloudValidations/validationExecutionPlans/executionPlanRuns", + ]) { + assert.isTrue( + operations.some( + (operation) => + operation.name?.toLowerCase() === + `Microsoft.PlatformValidation/${resource}/read`.toLowerCase(), + ), + `Missing read operation for ${resource}`, + ); + } + }); + + it("lists and gets test categories with subscription-scoped IDs", async () => { + const categories = await collect(client.validationTestCategories.listBySubscription()); + assert.isNotEmpty(categories); + const category = categories[0]; + assert.isString(category.name); + const retrieved = await client.validationTestCategories.get(category.name!); + assert.equal(retrieved.name, category.name); + assert.equal(retrieved.id, category.id); + assert.equal( + retrieved.id?.toLowerCase(), + `/subscriptions/${assertEnvironmentVariable("AZURE_SUBSCRIPTION_ID")}/providers/Microsoft.PlatformValidation/validationTestCategories/${category.name}`.toLowerCase(), + ); + }); + + it("lists and gets validation tests with subscription-scoped IDs", async () => { + const tests = await collect(client.validationTests.listBySubscription()); + const test = tests.find((entry) => entry.name === bootTestName); + assert.isDefined(test, "The approved boot validation must be discoverable"); + const retrieved = await client.validationTests.get(bootTestName); + assert.equal(retrieved.id, test!.id); + assert.isNotEmpty(retrieved.properties?.displayName); + assert.equal( + retrieved.id?.toLowerCase(), + `/subscriptions/${assertEnvironmentVariable("AZURE_SUBSCRIPTION_ID")}${bootTestId}`.toLowerCase(), + ); + }); + + it("lists and gets test versions with subscription-scoped IDs", async () => { + const versions = await collect(client.validationTestVersions.list(bootTestName)); + assert.isNotEmpty(versions); + const version = versions[0]; + assert.isString(version.name); + const retrieved = await client.validationTestVersions.get(bootTestName, version.name!); + assert.equal(retrieved.id, version.id); + assert.equal( + retrieved.id?.toLowerCase(), + `/subscriptions/${assertEnvironmentVariable("AZURE_SUBSCRIPTION_ID")}${bootTestId}/versions/${version.name}`.toLowerCase(), + ); + }); + + it.each(["cloud validation lifecycle", "execution plan lifecycle", "boot execution"])( + "runs %s", + async (scenario) => { + const group = assertEnvironmentVariable("PLATFORMVALIDATION_RESOURCE_GROUP"); + const location = assertEnvironmentVariable("PLATFORMVALIDATION_LOCATION"); + const source = + scenario === "cloud validation lifecycle" + ? undefined + : requireCredentialFreeVhd( + assertEnvironmentVariable("PLATFORMVALIDATION_SOURCE_VHD_URI"), + ); + const cvName = recorder.variable( + "cloudValidationName", + `jscv${randomUUID().replace(/-/g, "").slice(0, 20)}`, + ); + const planName = "js-boot-plan"; + const runName = "js-boot-run"; + const cvId = `/subscriptions/${assertEnvironmentVariable("AZURE_SUBSCRIPTION_ID")}/resourceGroups/${group}/providers/Microsoft.PlatformValidation/cloudValidations/${cvName}`; + const scopedBootTestId = `/subscriptions/${assertEnvironmentVariable("AZURE_SUBSCRIPTION_ID")}${bootTestId}`; + await recorder.addSanitizers(ownedCloudValidationSanitizers(cvId)); + const configuration = JSON.stringify({ + apiVersion: "microsoft.validate/executionPlan.v0", + kind: "ExecutionPlan", + metadata: { name: planName }, + parameters: { + certificationPackageReference: { + osType: "Linux", + vmGenerationType: "V1", + architectureType: "X64", + recommendedVMSizes: ["Standard_D4s_v3"], + storageProfile: { osDiskImage: { sourceVhdUri: source }, dataDiskImages: [] }, + additionalProperties: {}, + }, + }, + authoring: { steps: [{ name: "vm-boot-test", type: "test", testRef: scopedBootTestId }] }, + }); + const options = { + abortSignal: AbortSignal.timeout(30 * 60 * 1000), + updateIntervalInMs: isPlaybackMode() ? 0 : 1000, + }; + const mutation = { inProgress: false }; + let cvAttempted = false; + let planAttempted = false; + let runAttempted = false; + let runTerminal = false; + + async function complete( + poller: PollerLike, T>, + timeoutMinutes = 15, + ): Promise { + return completeMutation(mutation, poller, timeoutMinutes); + } + + await assertNotFound(() => client.cloudValidations.get(group, cvName, options)); + console.log( + `JS owned resources: group=${group}; cv=${cvName}; plan=${planName}; run=${runName}`, + ); + try { + cvAttempted = true; + const cv = await complete( + client.cloudValidations.createOrUpdate( + group, + cvName, + { + location, + tags: { "sdk-test": cvName }, + properties: { description: "JS SDK owned validation" }, + }, + options, + ), + ); + assert.equal(cv.id?.toLowerCase(), cvId.toLowerCase()); + assert.equal(cv.properties?.provisioningState, "Succeeded"); + assert.equal((await client.cloudValidations.get(group, cvName, options)).id, cv.id); + assert.isTrue( + (await collect(client.cloudValidations.listByResourceGroup(group, options))).some( + (entry) => entry.id === cv.id, + ), + ); + assert.isTrue( + (await collect(client.cloudValidations.listBySubscription(options))).some( + (entry) => entry.id === cv.id, + ), + ); + if (scenario === "cloud validation lifecycle") { + await complete( + client.cloudValidations.update( + group, + cvName, + { + tags: { "sdk-test": cvName, phase: "updated" }, + properties: { description: "JS SDK updated validation" }, + }, + options, + ), + ); + const updated = await client.cloudValidations.get(group, cvName, options); + assert.equal(updated.properties?.description, "JS SDK updated validation"); + assert.deepEqual(updated.tags, { "sdk-test": cvName, phase: "updated" }); + return; + } + + planAttempted = true; + const plan = await complete( + client.validationExecutionPlans.createOrUpdate( + group, + cvName, + planName, + { + location, + tags: { "sdk-test": cvName }, + properties: { description: "JS SDK boot plan", planConfigurationJson: configuration }, + }, + options, + ), + ); + assert.equal(plan.properties?.provisioningState, "Succeeded"); + assert.equal( + (await client.validationExecutionPlans.get(group, cvName, planName, options)).id, + plan.id, + ); + assert.deepEqual( + ( + await collect( + client.validationExecutionPlans.listByResourceGroup(group, cvName, options), + ) + ).map((entry) => entry.id), + [plan.id], + ); + if (scenario === "execution plan lifecycle") { + await complete( + client.validationExecutionPlans.update( + group, + cvName, + planName, + { tags: { "sdk-test": cvName, phase: "updated" } }, + options, + ), + ); + const patched = await client.validationExecutionPlans.get( + group, + cvName, + planName, + options, + ); + assert.deepEqual(patched.tags, { "sdk-test": cvName, phase: "updated" }); + assert.equal(patched.properties?.description, "JS SDK boot plan"); + // Keep the image configuration out of assertion diagnostics. + assert.isTrue( + JSON.stringify(JSON.parse(patched.properties?.planConfigurationJson ?? "{}")) === + JSON.stringify(JSON.parse(configuration)), + "Plan PATCH must preserve the image configuration", + ); + return; + } + + runAttempted = true; + let run = await complete( + client.executionPlanRuns.createOrUpdate( + group, + cvName, + planName, + runName, + { properties: { description: "JS SDK boot execution" } }, + options, + ), + 20, + ); + // Aggregate completion can precede the child result's provisioning completion. + mutation.inProgress = true; + for (let attempt = 0; attempt < 180; attempt++) { + runTerminal = ["Completed", "Succeeded", "Failed", "TimedOut", "Canceled"].includes( + run.properties?.status ?? "", + ); + if (runTerminal) { + break; + } + await delay(isPlaybackMode() ? 0 : 10000, { abortSignal: options.abortSignal }); + run = await client.executionPlanRuns.get(group, cvName, planName, runName, options); + } + assert.isTrue( + runTerminal, + "Boot execution did not reach a terminal state; resources retained", + ); + assert.include(["Completed", "Succeeded"], run.properties?.status); + assert.equal(run.properties?.provisioningState, "Succeeded"); + assert.deepInclude(run.properties?.testRunSummary, { + totalTests: 1, + passedTests: 1, + failedTests: 0, + skippedTests: 0, + overallResult: "Passed", + }); + assert.equal( + (await client.executionPlanRuns.get(group, cvName, planName, runName, options)).id, + run.id, + ); + assert.deepEqual( + ( + await collect( + client.executionPlanRuns.listByExecutionPlan(group, cvName, planName, options), + ) + ).map((entry) => entry.id), + [run.id], + ); + const results = await collect( + client.validationTestRuns.listByExecutionPlanRun( + group, + cvName, + planName, + runName, + options, + ), + ); + assert.lengthOf(results, 1); + const result = await waitForTerminalTestRun( + mutation, + () => + client.validationTestRuns.get( + group, + cvName, + planName, + runName, + results[0].name!, + options, + ), + () => delay(isPlaybackMode() ? 0 : 1000, { abortSignal: options.abortSignal }), + ); + assert.equal(result.id, results[0].id); + assert.equal(result.properties?.status, "Completed"); + assert.equal(result.properties?.provisioningState, "Succeeded"); + assert.equal(result.properties?.testId?.toLowerCase(), scopedBootTestId.toLowerCase()); + assert.isNotEmpty(result.properties?.passDetails); + assert.isEmpty(result.properties?.failureDetails ?? []); + assert.isUndefined(result.properties?.error); + } finally { + // Never cascade-delete resources underneath an uncertain mutation or active workload. + if (mutation.inProgress || (runAttempted && !runTerminal)) { + console.error(`Retained owned resources for inspection: ${cvId}`); + } else { + const cleanup = { + abortSignal: AbortSignal.timeout(15 * 60 * 1000), + updateIntervalInMs: isPlaybackMode() ? 0 : 1000, + }; + if (runAttempted) { + await deleteIfPresent(() => + client.executionPlanRuns + .delete(group, cvName, planName, runName, cleanup) + .pollUntilDone(), + ); + await assertNotFound(() => + client.executionPlanRuns.get(group, cvName, planName, runName, cleanup), + ); + assert.isEmpty( + await collect( + client.executionPlanRuns.listByExecutionPlan(group, cvName, planName, cleanup), + ), + ); + } + if (planAttempted) { + await deleteIfPresent(() => + client.validationExecutionPlans + .delete(group, cvName, planName, cleanup) + .pollUntilDone(), + ); + await assertNotFound(() => + client.validationExecutionPlans.get(group, cvName, planName, cleanup), + ); + assert.isEmpty( + await collect( + client.validationExecutionPlans.listByResourceGroup(group, cvName, cleanup), + ), + ); + } + if (cvAttempted) { + for (let attempt = 0; ; attempt++) { + assert.isEmpty( + await collect( + client.validationExecutionPlans.listByResourceGroup(group, cvName, cleanup), + ), + "Refusing parent deletion while child plans remain", + ); + try { + await deleteIfPresent(() => + client.cloudValidations.delete(group, cvName, cleanup).pollUntilDone(), + ); + break; + } catch (error) { + // ARM can retain a deleted child briefly after GET and LIST confirm its removal. + if ( + !isRestError(error) || + error.statusCode !== 409 || + error.code !== "CannotDeleteResource" || + attempt >= 29 + ) { + throw error; + } + await delay(isPlaybackMode() ? 0 : 10000, { abortSignal: cleanup.abortSignal }); + } + } + await assertNotFound(() => client.cloudValidations.get(group, cvName, cleanup)); + assert.isFalse( + (await collect(client.cloudValidations.listByResourceGroup(group, cleanup))).some( + (entry) => entry.id?.toLowerCase() === cvId.toLowerCase(), + ), + ); + } + } + } + }, + 45 * 60 * 1000, + ); +}); diff --git a/sdk/platformvalidation/arm-platformvalidation/test/public/sampleTest.spec.ts b/sdk/platformvalidation/arm-platformvalidation/test/public/sampleTest.spec.ts deleted file mode 100644 index 02e40ba0c606..000000000000 --- a/sdk/platformvalidation/arm-platformvalidation/test/public/sampleTest.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// import { Recorder } from "@azure-tools/test-recorder"; -// import { createRecorder } from "./utils/recordedClient.js"; -import { - assert, - // beforeEach, - // afterEach, - it, - describe, -} from "vitest"; - -describe("My test", () => { - // let recorder: Recorder; - - // beforeEach(async function(ctx) { - // recorder = await createRecorder(ctx); - // }); - - // afterEach(async function() { - // await recorder.stop(); - // }); - - it("sample test", async function () { - assert.equal(1, 1); - }); -}); diff --git a/sdk/platformvalidation/arm-platformvalidation/test/public/utils/lifecycle.ts b/sdk/platformvalidation/arm-platformvalidation/test/public/utils/lifecycle.ts new file mode 100644 index 000000000000..c781d1cb3c57 --- /dev/null +++ b/sdk/platformvalidation/arm-platformvalidation/test/public/utils/lifecycle.ts @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { OperationState, PollerLike } from "@azure/core-lro"; +import { isRestError } from "@azure/core-rest-pipeline"; +import type { ValidationTestRun } from "../../../src/index.js"; + +export function requireCredentialFreeVhd(value: string): string { + const message = "An approved credential-free HTTPS VHD URI is required"; + let uri: URL; + try { + uri = new URL(value); + } catch { + throw new Error(message); + } + if ( + uri.protocol !== "https:" || + uri.username || + uri.password || + uri.search || + uri.hash || + !uri.pathname.toLowerCase().endsWith(".vhd") + ) { + throw new Error(message); + } + return value; +} + +export async function completeMutation( + state: { inProgress: boolean }, + poller: Pick, T>, "pollUntilDone">, + timeoutMinutes = 15, +): Promise { + state.inProgress = true; + const result = await poller.pollUntilDone({ + abortSignal: AbortSignal.timeout(timeoutMinutes * 60 * 1000), + }); + // A polling exception cannot establish that the remote mutation has stopped. + state.inProgress = false; + return result; +} + +export async function deleteIfPresent(action: () => Promise): Promise { + try { + await action(); + } catch (error) { + if (!isRestError(error) || error.statusCode !== 404) { + throw error; + } + } +} + +export async function waitForTerminalTestRun( + state: { inProgress: boolean }, + read: () => Promise, + pause: () => Promise, +): Promise { + state.inProgress = true; + for (let attempt = 0; attempt < 180; attempt++) { + const result = await read(); + if ( + ["Succeeded", "Failed", "Canceled"].includes(result.properties?.provisioningState ?? "") && + ["Completed", "Failed", "TimedOut", "Canceled"].includes(result.properties?.status ?? "") + ) { + state.inProgress = false; + return result; + } + if (attempt < 179) { + await pause(); + } + } + throw new Error("Test result did not reach a terminal state; resources retained"); +} diff --git a/sdk/platformvalidation/arm-platformvalidation/test/public/utils/recordedClient.ts b/sdk/platformvalidation/arm-platformvalidation/test/public/utils/recordedClient.ts index 285e4643c45e..d7f7572a668e 100644 --- a/sdk/platformvalidation/arm-platformvalidation/test/public/utils/recordedClient.ts +++ b/sdk/platformvalidation/arm-platformvalidation/test/public/utils/recordedClient.ts @@ -1,17 +1,95 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { RecorderStartOptions, VitestTestContext } from "@azure-tools/test-recorder"; -import { Recorder } from "@azure-tools/test-recorder"; +import type { + RecorderStartOptions, + SanitizerOptions, + VitestTestContext, +} from "@azure-tools/test-recorder"; +import { assertEnvironmentVariable, Recorder } from "@azure-tools/test-recorder"; +import { createTestCredential } from "@azure-tools/test-credential"; +import { PlatformValidationClient } from "../../../src/index.js"; const replaceableVariables: Record = { - SUBSCRIPTION_ID: "azure_subscription_id", + AZURE_SUBSCRIPTION_ID: "00000000-0000-0000-0000-000000000000", + AZURE_TENANT_ID: "88888888-8888-8888-8888-888888888888", + PLATFORMVALIDATION_RESOURCE_GROUP: "rg-platformvalidation-test", + PLATFORMVALIDATION_LOCATION: "eastus2euap", + PLATFORMVALIDATION_ARM_ENDPOINT: "https://recording.management.azure.com", + PLATFORMVALIDATION_SOURCE_VHD_URI: "https://example.invalid/image.vhd", }; const recorderEnvSetup: RecorderStartOptions = { envSetupForPlayback: replaceableVariables, + // ARM identities and polling routes must survive sanitization for subsequent SDK calls. + removeCentralSanitizers: ["AZSDK3430", "AZSDK3493", "AZSDK2030"], + sanitizerOptions: { + headerSanitizers: [{ key: "x-ms-operation-identifier", value: "Sanitized" }], + generalSanitizers: [ + { + regex: true, + target: "[A-Za-z0-9._%+-]+@microsoft\\.com", + value: "sanitized@example.invalid", + }, + ], + bodyKeySanitizers: [ + { jsonPath: "$..systemData.createdBy", value: "sanitized@example.invalid" }, + { jsonPath: "$..systemData.lastModifiedBy", value: "sanitized@example.invalid" }, + { jsonPath: "$..principalId", value: "00000000-0000-0000-0000-000000000000" }, + ...["planConfigurationJson", "planConfigurationSnapshot", "inputsJson"].map((key) => ({ + jsonPath: `$..${key}`, + regex: 'https://[^"\\s]+?\\.vhd(?:\\?[^"\\s]*)?', + value: "https://example.invalid/image.vhd", + })), + ], + }, }; +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\//g, "\\/"); +} + +export function ownedCloudValidationSanitizers(ownedId: string): SanitizerOptions { + const group = + /^\/subscriptions\/[^/]+\/resourceGroups\/([^/]+)\/providers\/Microsoft\.PlatformValidation\/cloudValidations\/[^/]+$/i.exec( + ownedId, + )?.[1]; + if (!group) { + throw new Error("An owned cloud validation resource ID is required"); + } + const sanitizedId = ownedId + .replace( + /^\/subscriptions\/[^/]+/i, + `/subscriptions/${replaceableVariables.AZURE_SUBSCRIPTION_ID}`, + ) + .replace( + /\/resourceGroups\/[^/]+/i, + `/resourceGroups/${replaceableVariables.PLATFORMVALIDATION_RESOURCE_GROUP}`, + ); + const identities = `(?:${escapeRegex(ownedId)}|${escapeRegex(sanitizedId)})`; + const listedValidations = "$.value[?(@.type =~ /cloudvalidations$/i)]"; + return { + bodyKeySanitizers: [ + { jsonPath: `${listedValidations}.tags.*`, value: "Sanitized" }, + { jsonPath: `${listedValidations}.properties.description`, value: "Sanitized" }, + { jsonPath: `${listedValidations}.name`, value: "other-cloud-validation" }, + ], + bodySanitizers: [ + { + regex: true, + target: `(?i)(?!${identities}(?=[/"\\\\?]|$))/subscriptions/[^/"\\\\]+/resourceGroups/[^/"\\\\]+/providers/Microsoft\\.PlatformValidation/cloudValidations/(?[^/"\\\\?]+)`, + groupForReplace: "name", + value: "other-cloud-validation", + }, + { + regex: true, + target: `(?i)/resourceGroups/(?!(?:${escapeRegex(group)}|${replaceableVariables.PLATFORMVALIDATION_RESOURCE_GROUP})(?=[/"\\\\?]|$))[^/"\\\\?]+`, + value: "/resourceGroups/other-group", + }, + ], + }; +} + /** * creates the recorder and reads the environment variables from the `.env` file. * Should be called first in the test suite to make sure environment variables are @@ -22,3 +100,13 @@ export async function createRecorder(context: VitestTestContext): Promise