* 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>
367 lines
11 KiB
JavaScript
367 lines
11 KiB
JavaScript
/**
|
|
* 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();
|
|
});
|
|
},
|
|
);
|