Files
threshold-ui/e2e-tests/helpers/boundary-api-client.ts
T
hashiccandZhihe Li b42ecbff36 Add api client for playwright e2e tests (#2901)
* chore: 🤖 add @openapitools/openapi-generator-cli

* chore: 🤖 Add @nestjs/core and @openapitools/openapi-generator-cli to ignoredBuiltDependencies

These packages use postinstall for a opencollective notice

* chore: 🤖 add package.json scripts for open-api client gen

* chore: 🤖 add openapi generated files

* chore: 🤖 add boundary api client fixture

* chore: 🤖 incorporate api client fixture with base fixture

* chore: 🤖 migrate existing test to use boundary api fixture

* refactor: 💡 remove org cleanup from test

* refactor: 💡 update sorting e2e test to use api client

* refactor: 💡 clean up api client resource clean up logging

* docs: ✏️ update jsdoc links for domain clients

* chore: 🤖 avoid adding copywright headers to generated api

* Add missing copyright headers

* chore: 🤖 remove fetch argument from api client class

The hope was to use request.fetch but playwright's fetch is a
non-standard fetch which causes the generated api client to fail when
using .clone() on the response

* chore: 🤖 add optional OPENAPI_SWAGGER_URL_OR_FILE for openapi-client
generation

The OPENAPI_SWAGGER_URL_OR_FILE env var can be used to specify the
-i flag the generate:api-client script with either:
* a relative path
* an absolute path
* a url like: https://raw.githubusercontent.com/hashicorp/boundary/refs/heads/main/internal/gen/controller.swagger.json

* refactor: 💡 clean up and refactor boundary-api-client fixture

* chore: 🤖 add extra doc links and delete methods

* test: 💍 add test to ensure that api-client fixture works

* test: 💍 update existing e2e test examples with api-client

* docs: ✏️ add comment for api-client e2e test

* docs: ✏️ add additional links to service docs for api clients

* refactor: 💡 cleanup api-client and corresponding test

* docs: ✏️ add api client fixture docs to e2e README

* fix: 🐛 fix regex

* remove unnecessary capture group
* add "-" to regex to allow for url segments like "storage-buckets" in
  "/v1/storage-buckets"

* docs: ✏️ comment potential edge case in e2e api client cleanup

* docs: ✏️ fix comment typo

Co-authored-by: Zhihe Li <zhihe.li@hashicorp.com>

* docs: ✏️ remove auth flag for generating e2e client

* docs: ✏️ add mention that java is required for generate:api

* chore: 🤖 implement pr feedback

* chore: 🤖 Clean up api client and add more extensive test

* chore: 🤖 fix casing in api-client test

Co-authored-by: Zhihe Li <zhihe.li@hashicorp.com>

---------

Co-authored-by: Zhihe Li <zhihe.li@hashicorp.com>
2025-08-12 18:08:35 -04:00

384 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?: () => {};
}>({
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);
}
});
}),
);
}
},
});