/** * Copyright (c) HashiCorp, Inc. * SPDX-License-Identifier: BUSL-1.1 */ import { test as base } from '@playwright/test'; import * as BoundaryApiClient from '../api-client'; const BoundaryE2EResourceCleanupHeader = 'X-Boundary-E2E-Resource-Cleanup'; // These constants represent assumptions around the boundary api const BoundaryApiStatusCodes = Object.freeze({ ok: 200, deleted: 204, notFound: 404, }); // the openapi spec generated incorrectly specifies that DELETE responses will be a 200 with a json body, // but they actually return a 204 "No Content" status with no body. The generated api client does not handle // this correctly, so we need to patch it here so when it tries to access the `json` method on the response // it does not throw const patchEmptyResponsesMiddleware = { async post(context: BoundaryApiClient.ResponseContext) { const isBoundaryApiDeleteResponse = context.response.status === BoundaryApiStatusCodes.deleted && context.response.body === null; if (isBoundaryApiDeleteResponse) { context.response.json = async () => undefined; } return context.response; }, }; const betterErrorHandlingMiddleware = { async post(context: BoundaryApiClient.ResponseContext) { // skip logging of cleanup requests because it is common for the resource to already // be cleaned up and the result of trying again will be a 404. We only want to log // requests that fail as part of test or test setup so that they can be debugged if (context.init.headers?.[BoundaryE2EResourceCleanupHeader] === 'true') { return; } const responseStatusCodeIsInErrorRange = context.response.status >= 400; if (responseStatusCodeIsInErrorRange) { let errorResponseMessage; // a response can only be read once, and is read by the generated api client, // cloning the response allows the cloned response to be read, leaving the // original response to be read by the generated api client const clonedResponse = context.response.clone(); try { errorResponseMessage = await clonedResponse.text(); } finally { console.error( `Error response from Boundary API, status code: ${clonedResponse.status}, with method: ${context.init.method}, on ${clonedResponse.url}${errorResponseMessage ? `:\n${errorResponseMessage}` : ''}`, ); } } return context.response; }, }; function extractPluralizedResourceTypeFromResourceCreationUrl( url: string, ): string | undefined { const match = new URL(url).pathname.match(/^\/v1\/([a-z-]+)$/); if (!match) return undefined; const [_, type] = match; return type; } class BoundaryApi { // `createdResources` is keyed by the pluralized resource type, e.g. "roles", "scopes", "targets" // this is what is exracted from the url in `captureCreatedResourcesMiddleware` readonly createdResources: Record = {}; readonly skipCleanupResources: { id: string }[] = []; constructor(readonly controllerAddr: string) {} get clients() { const { openapiConfiguration } = this; // `clients` could be generated dynamically, but by doing it this way the type annotations are // better preserved for each client, and the "@link" jsdoc appear in the editor const clients = { /** {@link https://developer.hashicorp.com/boundary/docs/concepts/domain-model/accounts Accounts Documentation} */ Account: new BoundaryApiClient.AccountServiceApi(openapiConfiguration), /** {@link https://developer.hashicorp.com/boundary/docs/concepts/domain-model/aliases Aliases Method Documentation} */ Alias: new BoundaryApiClient.AliasServiceApi(openapiConfiguration), /** {@link https://developer.hashicorp.com/boundary/docs/concepts/domain-model/auth-methods Auth Method Documentation} */ AuthMethod: new BoundaryApiClient.AuthMethodServiceApi( openapiConfiguration, ), /** {@link https://developer.hashicorp.com/boundary/api-docs/auth-token-service Auth Token Service Api Docs} */ AuthToken: new BoundaryApiClient.AuthTokenServiceApi( openapiConfiguration, ), /** {@link https://developer.hashicorp.com/boundary/api-docs/billing-service Billing Service Api Docs} */ Billing: new BoundaryApiClient.BillingServiceApi(openapiConfiguration), /** {@link https://developer.hashicorp.com/boundary/docs/concepts/domain-model/credential-libraries Credential Libraries Documentation} */ CredentialLibrary: new BoundaryApiClient.CredentialLibraryServiceApi( openapiConfiguration, ), /** {@link https://developer.hashicorp.com/boundary/docs/concepts/domain-model/credentials Credentials Documentation} */ Credential: new BoundaryApiClient.CredentialServiceApi( openapiConfiguration, ), /** {@link https://developer.hashicorp.com/boundary/docs/concepts/domain-model/credential-stores Credential Stores Documentation} */ CredentialStore: new BoundaryApiClient.CredentialStoreServiceApi( openapiConfiguration, ), /** {@link https://developer.hashicorp.com/boundary/docs/concepts/domain-model/groups Groups Documentation} */ Group: new BoundaryApiClient.GroupServiceApi(openapiConfiguration), /** {@link https://developer.hashicorp.com/boundary/docs/concepts/domain-model/hosts Hosts Documentation} */ Host: new BoundaryApiClient.HostServiceApi(openapiConfiguration), /** {@link https://developer.hashicorp.com/boundary/docs/concepts/domain-model/host-catalogs Host Catalogs Documentation} */ HostCatalog: new BoundaryApiClient.HostCatalogServiceApi( openapiConfiguration, ), /** {@link https://developer.hashicorp.com/boundary/docs/concepts/domain-model/host-sets Host Sets Documentation} */ HostSet: new BoundaryApiClient.HostSetServiceApi(openapiConfiguration), /** {@link https://developer.hashicorp.com/boundary/docs/concepts/domain-model/managed-groups Managed Groups Documentation} */ ManagedGroup: new BoundaryApiClient.ManagedGroupServiceApi( openapiConfiguration, ), /** {@link https://developer.hashicorp.com/boundary/api-docs/policy-service Policy Service Api Docs} */ Policy: new BoundaryApiClient.PolicyServiceApi(openapiConfiguration), /** {@link https://developer.hashicorp.com/boundary/docs/concepts/domain-model/roles Roles Documentation} */ Role: new BoundaryApiClient.RoleServiceApi(openapiConfiguration), /** {@link https://developer.hashicorp.com/boundary/docs/concepts/domain-model/scopes Scopes Documentation} */ Scope: new BoundaryApiClient.ScopeServiceApi(openapiConfiguration), /** {@link https://developer.hashicorp.com/boundary/docs/concepts/domain-model/session-recordings Session Recordings Documentation} */ SessionRecording: new BoundaryApiClient.SessionRecordingServiceApi( openapiConfiguration, ), /** {@link https://developer.hashicorp.com/boundary/docs/concepts/domain-model/sessions Sessions Documentation} */ Session: new BoundaryApiClient.SessionServiceApi(openapiConfiguration), /** {@link https://developer.hashicorp.com/boundary/docs/concepts/domain-model/storage-buckets Storage Buckets Documentation} */ StorageBucket: new BoundaryApiClient.StorageBucketServiceApi( openapiConfiguration, ), /** {@link https://developer.hashicorp.com/boundary/docs/concepts/domain-model/targets Targets Documentation} */ Target: new BoundaryApiClient.TargetServiceApi(openapiConfiguration), /** {@link https://developer.hashicorp.com/boundary/docs/concepts/domain-model/users Users Documentation} */ User: new BoundaryApiClient.UserServiceApi(openapiConfiguration), /** {@link https://developer.hashicorp.com/boundary/api-docs/worker-service Worker Service Api Docs} */ Worker: new BoundaryApiClient.WorkerServiceApi(openapiConfiguration), }; return clients; } // it's assumed that ids are unique so the type isn't needed to be known when // iterating over created resources skipCleanup(identifiable: { id: string }): void { if (!identifiable.id) { throw new Error('The `id` field is expected on the resource'); } this.skipCleanupResources.push(identifiable); } private get openapiConfiguration() { const { controllerAddr, createdResources: resources } = this; const captureCreatedResourcesMiddleware = { async post(context) { // only json responses are expected for created resources if ( context.response.status !== BoundaryApiStatusCodes.ok || context.response.headers.get('content-type') !== 'application/json' ) { return context.response; } // the response `json` can only be read once, and needs to be read later by the generated api client, // instead we can clone the response and this allows us to read the `json` on the cloned response only const json = await context.response.clone().json(); // created responses should have a valid "id" field if (!json.id) { return context.response; } // the url is the being used to determine the resource type, it returns the type in pluralized form const pluralizedResourceType = extractPluralizedResourceTypeFromResourceCreationUrl( context.response.url, ); if (!pluralizedResourceType) { return context.response; } resources[pluralizedResourceType] ??= []; // capture the created json payload, all that's really needed is the `id` and the `type` // in order to automatically clean it up later resources[pluralizedResourceType].push(json); // return original response return context.response; }, }; const openapiConfiguration = new BoundaryApiClient.Configuration({ basePath: controllerAddr, headers: { Authorization: `Bearer ${process.env.E2E_TOKEN}` }, middleware: [ captureCreatedResourcesMiddleware, betterErrorHandlingMiddleware, patchEmptyResponsesMiddleware, ], }); return openapiConfiguration; } } export const boundaryApiClientTest = base.extend<{ apiClient: BoundaryApi; controllerAddr?: string; apiClientTestAfter?: () => void; }>({ apiClientTestAfter: async ({}, use) => { // this is a noop so that the `apiClient` fixture doesn't fail for other tests, // the `apiClientTestAfter` fixture is used in api-client tests to provide a hook for assertions await use(() => {}); }, apiClient: async ({ controllerAddr, apiClientTestAfter }, use) => { // by destructuring the `apiClientTestAfter` fixture it creates a dependency on that fixture which // controls the setup/teardown order and allows `api-client.spec.js` to test the teardown cleanup // of this fixture apiClientTestAfter; if (!controllerAddr) { throw new Error('`controllerAddr` must be set in the base fixture'); } const boundaryApi = new BoundaryApi(controllerAddr); // what is passed to `use` is what is provided by the fixture in the test await use(boundaryApi); // anything after awaiting `use` is ran after the test is ran const { clients } = boundaryApi; // These delete methods have to be keyed of the pluralized resource type because // that is how the resources are stored in `createdResources` and how the type is // extracted from the url const cleanUpMethods = { accounts: clients.Account.accountServiceDeleteAccount.bind( clients.Account, ), aliases: clients.Alias.aliasServiceDeleteAlias.bind(clients.Alias), ['auth-tokens']: clients.AuthToken.authTokenServiceDeleteAuthToken.bind( clients.AuthToken, ), ['auth-methods']: clients.AuthMethod.authMethodServiceDeleteAuthMethod.bind( clients.AuthMethod, ), ['credential-libraries']: clients.CredentialLibrary.credentialLibraryServiceDeleteCredentialLibrary.bind( clients.CredentialLibrary, ), credentials: clients.Credential.credentialServiceDeleteCredential.bind( clients.Credential, ), ['credential-stores']: clients.CredentialStore.credentialStoreServiceDeleteCredentialStore.bind( clients.CredentialStore, ), groups: clients.Group.groupServiceDeleteGroup.bind(clients.Group), ['host-catalogs']: clients.HostCatalog.hostCatalogServiceDeleteHostCatalog.bind( clients.HostCatalog, ), ['host-sets']: clients.HostSet.hostSetServiceDeleteHostSet.bind( clients.HostSet, ), ['managed-groups']: clients.ManagedGroup.managedGroupServiceDeleteManagedGroup.bind( clients.ManagedGroup, ), policies: clients.Policy.policyServiceDeletePolicy.bind(clients.Policy), roles: clients.Role.roleServiceDeleteRole.bind(clients.Role), scopes: clients.Scope.scopeServiceDeleteScope.bind(clients.Scope), ['storage-buckets']: clients.StorageBucket.storageBucketServiceDeleteStorageBucket.bind( clients.StorageBucket, ), targets: clients.Target.targetServiceDeleteTarget.bind(clients.Target), users: clients.User.userServiceDeleteUser.bind(clients.User), workers: clients.Worker.workerServiceDeleteWorker.bind(clients.Worker), }; // clean up resources in batches per type for (const [pluralizedResourceType, resources] of Object.entries( boundaryApi.createdResources, )) { const cleanUpMethod = cleanUpMethods[pluralizedResourceType]; if (!cleanUpMethod) { throw new Error( `No delete method found for resource type: ${pluralizedResourceType}`, ); } // passed to cleanupMethod add `BoundaryE2EResourceCleanupHeader` header on // outgoing calls const cleanUpInitOverride = ({ init }) => { return { ...init, headers: { ...init.headers, [BoundaryE2EResourceCleanupHeader]: 'true', }, }; }; // this will race deletions for all unskipped created resources for a specific resource type await Promise.all( resources .filter((resource) => { return !boundaryApi.skipCleanupResources.some( (r) => r.id === resource.id, ); }) .map((resource) => { return cleanUpMethod( { id: resource.id }, cleanUpInitOverride, ).catch(async (error) => { // if the resource is not found it was likely already deleted and the error can be ignored if (error.response?.status === BoundaryApiStatusCodes.notFound) { return; } let responseDetails; if (error.response) { const clonedResponse = error.response.clone(); const { status, statusText, url } = clonedResponse; responseDetails = { status, statusText, url, body: await clonedResponse.text(), }; } console.warn( `Failed to clean up resource of type ${pluralizedResourceType} with id ${resource.id}:`, ); if (responseDetails) { console.warn(responseDetails); } }); }), ); } }, });