Files
threshold-ui/e2e-tests/admin/tests/api-client.spec.js
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

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();
});
},
);