2025-08-12 15:08:35 -07:00
/**
* 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 < string , { id : string } [] > = {};
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 ;
2025-09-10 07:14:55 -07:00
apiClientTestAfter ?: () => void ;
2025-08-12 15:08:35 -07:00
} > ({
2025-09-10 07:14:55 -07:00
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 (() => {});
},
2025-08-12 15:08:35 -07:00
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 );
}
});
}),
);
}
},
});