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>
This commit is contained in:
1 parent
5fd9f0216a
commit
b42ecbff36
262 files changed
+31646
-192
No files matched your search
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { test as base } from '../../global-setup.js';
|
||||
import { expect } from '@playwright/test';
|
||||
import { customAlphabet } from 'nanoid';
|
||||
|
||||
import * as boundaryCli from '../../helpers/boundary-cli';
|
||||
import { readFile } from 'fs/promises';
|
||||
|
||||
// These tests are to make sure that the api-client fixture works as expected.
|
||||
// Test helpers or fixtures don't always have tests, but because this one has
|
||||
// enough complexity it's worth testing
|
||||
|
||||
const nanoid = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyz', 5);
|
||||
const scopeId = 'global';
|
||||
const aliasCleanedUpDescription = `this alias should be cleaned up ${nanoid()}`;
|
||||
const aliasSkippedDescription = `this alias should not be cleaned up ${nanoid()}`;
|
||||
|
||||
const test = base.extend(
|
||||
{
|
||||
apiClientTestAfter: async ({}, use) => {
|
||||
let apiClientTestAfter;
|
||||
const setApiClientTestAfter = (fn) => {
|
||||
apiClientTestAfter = fn;
|
||||
};
|
||||
await use(setApiClientTestAfter);
|
||||
await apiClientTestAfter?.();
|
||||
},
|
||||
},
|
||||
{ scope: 'test', auto: true },
|
||||
);
|
||||
|
||||
test(
|
||||
'resources can be created (example: alias)',
|
||||
{ tag: ['@ce', '@ent', '@aws', '@docker'] },
|
||||
async ({ apiClient, apiClientTestAfter }) => {
|
||||
await apiClient.clients.Alias.aliasServiceCreateAlias({
|
||||
item: {
|
||||
scopeId,
|
||||
description: aliasCleanedUpDescription,
|
||||
value: `a-${nanoid(4)}.dev`,
|
||||
type: 'target',
|
||||
},
|
||||
});
|
||||
|
||||
const skipAlias = await apiClient.clients.Alias.aliasServiceCreateAlias({
|
||||
item: {
|
||||
scopeId,
|
||||
description: aliasSkippedDescription,
|
||||
value: `b-${nanoid(4)}.dev`,
|
||||
type: 'target',
|
||||
},
|
||||
});
|
||||
|
||||
apiClient.skipCleanup(skipAlias);
|
||||
|
||||
// These assertions ensure that by the end of this test the two aliases
|
||||
// exist. In the next test they are checked to see if clean up and
|
||||
// skipping clean up worked correctly
|
||||
const aliases = await apiClient.clients.Alias.aliasServiceListAliases({
|
||||
scopeId,
|
||||
});
|
||||
const cleanedUpAlias = aliases.items.find(
|
||||
({ description }) => description === aliasCleanedUpDescription,
|
||||
);
|
||||
expect(cleanedUpAlias).toBeTruthy();
|
||||
const cleanUpSkippedAlias = aliases.items.find(
|
||||
({ description }) => description === aliasSkippedDescription,
|
||||
);
|
||||
expect(cleanUpSkippedAlias).toBeTruthy();
|
||||
|
||||
apiClientTestAfter(async () => {
|
||||
const aliases = await apiClient.clients.Alias.aliasServiceListAliases({
|
||||
scopeId,
|
||||
});
|
||||
const cleanedUpAlias = aliases.items.find(
|
||||
({ description }) => description === aliasCleanedUpDescription,
|
||||
);
|
||||
// the `cleanedUpAlias` has been cleaned up so it isn't expected to be found
|
||||
expect(cleanedUpAlias).toBeFalsy();
|
||||
const cleanUpSkippedAlias = aliases.items.find(
|
||||
({ description }) => description === aliasSkippedDescription,
|
||||
);
|
||||
// the `cleanUpSkippedAlias` skipped cleanup so it should still exist
|
||||
expect(cleanUpSkippedAlias).toBeTruthy();
|
||||
|
||||
// manually clean up skipped resource
|
||||
await apiClient.clients.Alias.aliasServiceDeleteAlias({
|
||||
id: cleanUpSkippedAlias.id,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'it logs the response when an error is returned from an api call',
|
||||
{ tag: ['@ce', '@ent', '@aws', '@docker'] },
|
||||
async ({ apiClient }) => {
|
||||
const consoleErrors = [];
|
||||
const originalConsoleError = console.error;
|
||||
console.error = (toLog) => {
|
||||
consoleErrors.push(toLog);
|
||||
};
|
||||
|
||||
await apiClient.clients.Alias.aliasServiceListAliases({
|
||||
scopeId: 'not a valid scope id',
|
||||
// this request is expected to fail and throw
|
||||
}).catch(() => {});
|
||||
|
||||
expect(consoleErrors).toHaveLength(1);
|
||||
expect(consoleErrors[0]).toContain(
|
||||
'Error response from Boundary API, status code: 400, with method: GET',
|
||||
);
|
||||
expect(consoleErrors[0]).toContain('Improperly formatted identifier.');
|
||||
|
||||
// restore the original console.error
|
||||
console.error = originalConsoleError;
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'it throws when #skipCleanup is given an object without an id',
|
||||
{ tag: ['@ce', '@ent', '@aws', '@docker'] },
|
||||
({ apiClient }) => {
|
||||
expect(() =>
|
||||
apiClient.skipCleanup('not an object with an id property'),
|
||||
).toThrow('The `id` field is expected on the resource');
|
||||
},
|
||||
);
|
||||
|
||||
// This test serves as a more complex example and exercises many connected resources.
|
||||
// It also serves to illustrate that there are some resources, like storage buckets,
|
||||
// that may be dependent on other resources created outside the api being cleaned up first,
|
||||
// like sessions being cancelled and session recordings being deleted. Once these are
|
||||
// cleaned up then automatic clean up should work.
|
||||
test(
|
||||
'Storage buckets can be cleaned up after a session is manually cancelled and session recordings are manually deleted',
|
||||
{ tag: ['@ent', '@docker'] },
|
||||
async ({
|
||||
controllerAddr,
|
||||
adminAuthMethodId,
|
||||
adminLoginName,
|
||||
adminPassword,
|
||||
bucketAccessKeyId,
|
||||
bucketEndpointUrl,
|
||||
bucketName,
|
||||
bucketSecretAccessKey,
|
||||
region,
|
||||
sshUser,
|
||||
sshKeyPath,
|
||||
targetAddress,
|
||||
targetPort,
|
||||
workerTagEgress,
|
||||
apiClient,
|
||||
apiClientTestAfter,
|
||||
}) => {
|
||||
await boundaryCli.checkBoundaryCli();
|
||||
|
||||
let connect;
|
||||
try {
|
||||
await boundaryCli.authenticateBoundary(
|
||||
controllerAddr,
|
||||
adminAuthMethodId,
|
||||
adminLoginName,
|
||||
adminPassword,
|
||||
);
|
||||
|
||||
const org = await apiClient.clients.Scope.scopeServiceCreateScope({
|
||||
item: {
|
||||
name: `Org-${nanoid()}`,
|
||||
scopeId: 'global',
|
||||
},
|
||||
});
|
||||
|
||||
const project = await apiClient.clients.Scope.scopeServiceCreateScope({
|
||||
item: {
|
||||
name: `Project-${nanoid()}`,
|
||||
scopeId: org.id,
|
||||
},
|
||||
});
|
||||
|
||||
const storageBucket =
|
||||
await apiClient.clients.StorageBucket.storageBucketServiceCreateStorageBucket(
|
||||
{
|
||||
pluginName: 'minio',
|
||||
item: {
|
||||
scopeId: org.scopeId,
|
||||
bucketName,
|
||||
workerFilter: `"${workerTagEgress}" in "/tags/type"`,
|
||||
attributes: {
|
||||
region,
|
||||
endpoint_url: bucketEndpointUrl,
|
||||
disable_credential_rotation: true,
|
||||
},
|
||||
secrets: {
|
||||
access_key_id: bucketAccessKeyId,
|
||||
secret_access_key: bucketSecretAccessKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const target = await apiClient.clients.Target.targetServiceCreateTarget({
|
||||
item: {
|
||||
name: `ssh-target-${nanoid()}`,
|
||||
scopeId: project.id,
|
||||
type: 'ssh',
|
||||
address: targetAddress,
|
||||
ingressWorkerFilter: `"${workerTagEgress}" in "/tags/type"`,
|
||||
egressWorkerFilter: `"${workerTagEgress}" in "/tags/type"`,
|
||||
attributes: {
|
||||
default_port: targetPort,
|
||||
enable_session_recording: true,
|
||||
storage_bucket_id: storageBucket.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const credentialStore =
|
||||
await apiClient.clients.CredentialStore.credentialStoreServiceCreateCredentialStore(
|
||||
{
|
||||
item: {
|
||||
scopeId: project.id,
|
||||
type: 'static',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const keyData = await readFile(sshKeyPath, {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
|
||||
const credential =
|
||||
await apiClient.clients.Credential.credentialServiceCreateCredential({
|
||||
item: {
|
||||
credentialStoreId: credentialStore.id,
|
||||
type: 'ssh_private_key',
|
||||
attributes: {
|
||||
username: sshUser,
|
||||
private_key: keyData,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await apiClient.clients.Target.targetServiceAddTargetCredentialSources({
|
||||
id: target.id,
|
||||
body: {
|
||||
injectedApplicationCredentialSourceIds: [credential.id],
|
||||
version: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const storagePolicy =
|
||||
await apiClient.clients.Policy.policyServiceCreatePolicy({
|
||||
item: {
|
||||
type: 'storage',
|
||||
scopeId: 'global',
|
||||
attributes: {
|
||||
delete_after: { days: 1 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
apiClient.clients.Scope.scopeServiceAttachStoragePolicy({
|
||||
id: org.id,
|
||||
body: {
|
||||
storagePolicyId: storagePolicy.id,
|
||||
version: 1,
|
||||
},
|
||||
});
|
||||
|
||||
connect = await boundaryCli.connectSshToTarget(target.id);
|
||||
|
||||
let waitForSessionCount = 0;
|
||||
let sshSession;
|
||||
while (!sshSession) {
|
||||
const sessions =
|
||||
await apiClient.clients.Session.sessionServiceListSessions({
|
||||
scopeId: target.scopeId,
|
||||
});
|
||||
|
||||
sshSession = sessions?.items.find(
|
||||
(session) =>
|
||||
session.status === 'active' && session.targetId === target.id,
|
||||
);
|
||||
|
||||
if (waitForSessionCount >= 20) {
|
||||
throw new Error(
|
||||
'Max attempts exceeded waiting for target to session to be created',
|
||||
);
|
||||
}
|
||||
|
||||
// avoid being rate limited
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
waitForSessionCount++;
|
||||
}
|
||||
|
||||
await apiClient.clients.Session.sessionServiceCancelSession({
|
||||
id: sshSession.id,
|
||||
body: {
|
||||
version: sshSession.version,
|
||||
},
|
||||
});
|
||||
|
||||
let waitForSessionRecordingCount = 0;
|
||||
let completedSessionRecording;
|
||||
while (!completedSessionRecording) {
|
||||
completedSessionRecording = (
|
||||
await apiClient.clients.SessionRecording.sessionRecordingServiceListSessionRecordings(
|
||||
{
|
||||
scopeId: 'global',
|
||||
recursive: true,
|
||||
},
|
||||
)
|
||||
).items.find(
|
||||
(recording) =>
|
||||
recording.sessionId === sshSession.id &&
|
||||
recording.state === 'available',
|
||||
);
|
||||
|
||||
// Session recordings have to be cleaned up manually before a storage bucket can be deleted.
|
||||
// And session recordings can only be cleaned up once they are completed
|
||||
if (completedSessionRecording) {
|
||||
await apiClient.clients.SessionRecording.sessionRecordingServiceDeleteSessionRecording(
|
||||
{
|
||||
id: completedSessionRecording.id,
|
||||
body: { version: completedSessionRecording.version },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (waitForSessionRecordingCount >= 20) {
|
||||
throw new Error(
|
||||
'Max attempts exceeded waiting for target to session recording to be completed',
|
||||
);
|
||||
}
|
||||
|
||||
// avoid being rate limited
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
waitForSessionRecordingCount++;
|
||||
}
|
||||
} finally {
|
||||
// End `boundary connect` process
|
||||
connect?.kill('SIGTERM');
|
||||
}
|
||||
|
||||
apiClientTestAfter(async () => {
|
||||
const storageBuckets =
|
||||
(
|
||||
await apiClient.clients.StorageBucket.storageBucketServiceListStorageBuckets(
|
||||
{
|
||||
scopeId: 'global',
|
||||
recursive: true,
|
||||
},
|
||||
)
|
||||
).items ?? [];
|
||||
|
||||
expect(
|
||||
storageBuckets.find((bucket) => bucket.id === storageBuckets.id),
|
||||
).toBeUndefined();
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user