387 lines
15 KiB
TypeScript
387 lines
15 KiB
TypeScript
/**
|
|
* 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;
|
|
apiClientTestAfter?: () => {};
|
|
}>({
|
|
// 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
|
|
apiClientTestAfter: () => {},
|
|
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);
|
|
}
|
|
});
|
|
}),
|
|
);
|
|
}
|
|
},
|
|
});
|