diff --git a/.github/workflows/build-admin-ui.yaml b/.github/workflows/build-admin-ui.yaml index 3a30fcc87..3cc7dfe92 100644 --- a/.github/workflows/build-admin-ui.yaml +++ b/.github/workflows/build-admin-ui.yaml @@ -3,6 +3,7 @@ on: push: branches: - main + - session-recording jobs: dependencies: diff --git a/addons/api/addon/abilities/channel-recording.js b/addons/api/addon/abilities/channel-recording.js new file mode 100644 index 000000000..54164b881 --- /dev/null +++ b/addons/api/addon/abilities/channel-recording.js @@ -0,0 +1,26 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import ModelAbility from './model'; +import { inject as service } from '@ember/service'; + +/** + * Provides abilities for channel recordings. + */ +export default class ChannelRecordingAbility extends ModelAbility { + // =services + @service abilities; + + // =permissions + get canGetAsciicast() { + const sessionRecording = this.model.connection_recording.session_recording; + const sessionRecordingAbility = this.abilities.abilityFor( + 'session-recording', + sessionRecording + ); + + return this.model.isAsciicast && sessionRecordingAbility.canDownload; + } +} diff --git a/addons/api/addon/abilities/session-recording.js b/addons/api/addon/abilities/session-recording.js new file mode 100644 index 000000000..102d48aa6 --- /dev/null +++ b/addons/api/addon/abilities/session-recording.js @@ -0,0 +1,27 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import ModelAbility from './model'; + +/** + * Provides abilities for session recordings. + */ +export default class SessionRecordingAbility extends ModelAbility { + // =services + + // =permissions + + /** + * Only "known" session recording types may be read. + * @type {boolean} + */ + get canRead() { + return !this.model.isUnknown && super.canRead; + } + + get canDownload() { + return this.hasAuthorizedAction('download'); + } +} diff --git a/addons/api/addon/abilities/storage-bucket.js b/addons/api/addon/abilities/storage-bucket.js new file mode 100644 index 000000000..113519b67 --- /dev/null +++ b/addons/api/addon/abilities/storage-bucket.js @@ -0,0 +1,39 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import ModelAbility from './model'; + +/** + * Provides abilities for storage buckets. + */ +export default class StorageBucketAbility extends ModelAbility { + // =services + + // =permissions + + /** + * Only "known" storage bucket types may be read. + * @type {boolean} + */ + get canRead() { + return !this.model.isUnknown && super.canRead; + } + + /** + * Only "known" storage bucket types may be updated. + * @type {boolean} + */ + get canUpdate() { + return !this.model.isUnknown && super.canUpdate; + } + + /** + * Only "known" storage bucket types may be deleted. + * @type {boolean} + */ + get canDelete() { + return !this.model.isUnknown && super.canDelete; + } +} diff --git a/addons/api/addon/adapters/session-recording.js b/addons/api/addon/adapters/session-recording.js new file mode 100644 index 000000000..a5d47e336 --- /dev/null +++ b/addons/api/addon/adapters/session-recording.js @@ -0,0 +1,56 @@ +import ApplicationAdapter from './application'; +import { MIME_TYPE_ASCIICAST } from 'api/models/channel-recording'; + +export default class SessionRecordingAdapter extends ApplicationAdapter { + /** + * Overrides the default `buildURL` such that for custom download methods, + * URLs will get the query parameters. All other request types behave as normal. + * + * @param modelName {string} + * @param id {string} + * @param snapshot {object} + * @param requestType {string} + * @param query {object} + * @returns {string} + */ + buildURL(modelName, id, snapshot, requestType, query) { + let url = super.buildURL(...arguments); + const method = snapshot?.adapterOptions?.method; + if (method === 'download') { + const queryParams = new URLSearchParams(query).toString(); + if (queryParams) url = `${url}?${queryParams}`; + } + return url; + } + + /** + * Handles the download recording for recordings. Takes in a mime type for the + * type or recording to return. + * The ID can be the session ID, session recording ID, + * connection recording ID, or the channel recording ID. + * @param id {string} + * @param mimeType {string} + * @returns {Promise} + */ + async getRecordingDownload({ id, mimeType }) { + const options = { adapterOptions: { method: 'download' } }; + const url = this.buildURL('session-recording', id, options, 'findRecord', { + mime_type: mimeType, + }); + + const response = await fetch(url, { headers: this.headers }); + return response.text(); + } + + /** + * Returns an ascii cast recording. + * @param id + * @returns {Promise} + */ + getAsciicast(id) { + return this.getRecordingDownload({ + id, + mimeType: MIME_TYPE_ASCIICAST, + }); + } +} diff --git a/addons/api/addon/adapters/storage-bucket.js b/addons/api/addon/adapters/storage-bucket.js new file mode 100644 index 000000000..4f72f57a2 --- /dev/null +++ b/addons/api/addon/adapters/storage-bucket.js @@ -0,0 +1,23 @@ +import ApplicationAdapter from './application'; + +export default class StorageBucketAdapter extends ApplicationAdapter { + /** + * Overrides the default `buildURL` such that on _create_, URLs for records of + * type `plugin` get an extra query parameter `?plugin_name`. All other + * request types behave as normal. + * + * @override + * @param {string} modelName + * @param {string} id + * @return {string} url + * @return {string} requestType + */ + buildURL(modelName, id, snapshot, requestType) { + let url = super.buildURL(...arguments); + const pluginName = snapshot?.attr('plugin')?.name; + if (requestType === 'createRecord' && pluginName) { + url = `${url}?plugin_name=${pluginName}`; + } + return url; + } +} diff --git a/addons/api/addon/generated/models/channel-recording.js b/addons/api/addon/generated/models/channel-recording.js new file mode 100644 index 000000000..18a29db9d --- /dev/null +++ b/addons/api/addon/generated/models/channel-recording.js @@ -0,0 +1,59 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import BaseModel from '../../models/base'; +import { attr } from '@ember-data/model'; + +/** + * A channel contains data for a single channel within a connection. + */ +export default class GeneratedChannelRecordingModel extends BaseModel { + // =attributes + + @attr('string', { + description: + 'The total number of bytes uploaded from the client in this Channel.', + readOnly: true, + }) + bytes_up; + + @attr('string', { + description: + 'The total number of bytes downloaded to the client in this Channel.', + readOnly: true, + }) + bytes_down; + + @attr('number', { + description: + 'The total number of errors that occurred during the use of this Channel.', + readOnly: true, + }) + errors; + + @attr('date', { + description: 'The time the Channel started.', + readOnly: true, + }) + start_time; + + @attr('date', { + description: 'The time the Channel finished.', + readOnly: true, + }) + end_time; + + @attr({ + description: 'The array of mime types that this recording will support.', + readOnly: true, + }) + mime_types; + + @attr('duration', { + description: 'The duration of this channel recording in ms.', + readOnly: true, + }) + duration; +} diff --git a/addons/api/addon/generated/models/connection-recording.js b/addons/api/addon/generated/models/connection-recording.js new file mode 100644 index 000000000..bb015f7c5 --- /dev/null +++ b/addons/api/addon/generated/models/connection-recording.js @@ -0,0 +1,66 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import BaseModel from '../../models/base'; +import { attr } from '@ember-data/model'; + +/** + * A connection contains data for a boundary connection within a session. + */ +export default class GeneratedConnectionRecordingModel extends BaseModel { + // =attributes + + @attr('string', { + description: + 'The ID of the Connection which this Connection Recording recorded.', + readOnly: true, + }) + connection_id; + + @attr('string', { + description: + 'The total number of bytes uploaded from the client in this Connection.', + readOnly: true, + }) + bytes_up; + + @attr('string', { + description: + 'The total number of bytes downloaded to the client in this Connection.', + readOnly: true, + }) + bytes_down; + + @attr('number', { + description: + 'The total number of errors that occurred during the use of this Connection.', + readOnly: true, + }) + errors; + + @attr('date', { + description: 'The time the Connection started.', + readOnly: true, + }) + start_time; + + @attr('date', { + description: 'The time the Connection finished.', + readOnly: true, + }) + end_time; + + @attr({ + description: 'The array of mime types that this recording will support.', + readOnly: true, + }) + mime_types; + + @attr('duration', { + description: 'The duration of this connection recording in ms.', + readOnly: true, + }) + duration; +} diff --git a/addons/api/addon/generated/models/session-recording.js b/addons/api/addon/generated/models/session-recording.js new file mode 100644 index 000000000..391e494a6 --- /dev/null +++ b/addons/api/addon/generated/models/session-recording.js @@ -0,0 +1,112 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import BaseModel from '../../models/base'; +import { attr } from '@ember-data/model'; + +/** + * A session recording contains all data related to session recordings + */ +export default class GeneratedSessionRecordingModel extends BaseModel { + // =attributes + + @attr('string', { + description: 'The type of the resource, to help differentiate schemas.', + readOnly: true, + }) + type; + + @attr('date', { + description: + 'The time the Session Recording was created in the controller.', + readOnly: true, + }) + created_time; + + @attr('date', { + description: 'The time of the most recent update to the Session Recording.', + readOnly: true, + }) + updated_time; + + @attr({ + description: + 'Contains the values of related fields at the time this Session Recording was created', + readOnly: true, + }) + create_time_values; + + @attr('string', { + description: 'The ID of the Session which this Session Recording recorded.', + readOnly: true, + }) + session_id; + + @attr('string', { + description: + 'The ID of the Storage Bucket for the Target of this Session Recording', + readOnly: true, + }) + storage_bucket_id; + + @attr('string', { + description: + 'The total number of bytes uploaded from the client in the Session.', + readOnly: true, + }) + bytes_up; + + @attr('string', { + description: + 'The total number of bytes downloaded to the client in the Session.', + readOnly: true, + }) + bytes_down; + + @attr('number', { + description: + 'The total number of errors that occurred during the use of the Session.', + readOnly: true, + }) + errors; + + @attr('date', { + description: 'The time the Session started.', + readOnly: true, + }) + start_time; + + @attr('date', { + description: 'The time the Session finished.', + readOnly: true, + }) + end_time; + + @attr({ + description: 'The array of mime types that this recording will support.', + readOnly: true, + }) + mime_types; + + @attr('duration', { + description: 'The duration of this session recording in ms.', + readOnly: true, + }) + duration; + + @attr('string', { + description: + 'The current state of the session recording. One of "started", "available" and "unknown".', + readOnly: true, + }) + state; + + @attr('string', { + description: + 'Any error seen during the closing of the session recording. Currently only set if state is "unknown".', + readOnly: true, + }) + error_details; +} diff --git a/addons/api/addon/generated/models/storage-bucket.js b/addons/api/addon/generated/models/storage-bucket.js new file mode 100644 index 000000000..a1973abc6 --- /dev/null +++ b/addons/api/addon/generated/models/storage-bucket.js @@ -0,0 +1,94 @@ +import BaseModel from '../../models/base'; +import { attr } from '@ember-data/model'; + +/** + * A storage bucket is a resource that that represents a bucket in an external object store. + */ +export default class GeneratedStorageBucketModel extends BaseModel { + // =attributes + + @attr('string', { + description: 'The type of the resource, to help differentiate schemas', + }) + type; + + @attr({ + description: 'Plugin information for this resource.', + readOnly: true, + }) + plugin; + + @attr('string', { + description: 'Optional name for identification purposes.', + }) + name; + + @attr('string', { + description: 'Optional user-set description for identification purposes.', + }) + description; + + @attr('date', { + description: 'The time this resource was created\nOutput only.', + readOnly: true, + }) + created_time; + + @attr('date', { + description: 'The time this resource was last updated\nOutput only.', + readOnly: true, + }) + updated_time; + + @attr('number', { + description: 'Current version number of this resource.', + }) + version; + + @attr('string', { + description: 'The name of the bucket in the external object store.', + }) + bucket_name; + + @attr('string', { + description: + 'The base path where session recordings will be stored in the external object store.', + }) + bucket_prefix; + + @attr('string', { + description: + 'An expression used to filter the workers that have network access to a service that is hosting the external object store.', + }) + worker_filter; + + @attr('string', { + isNestedAttribute: true, + description: 'The AWS region to use.', + }) + region; + + @attr('string', { + readOnly: true, + isNestedAttribute: true, + }) + secrets_hmac; + + @attr('string', { + isNestedSecret: true, + description: 'The AWS access key ID to use.', + }) + access_key_id; + + @attr('string', { + isNestedSecret: true, + description: 'The secret access key to use.', + }) + secret_access_key; + + @attr('boolean', { + isNestedAttribute: true, + description: '', + }) + disable_credential_rotation; +} diff --git a/addons/api/addon/generated/models/target.js b/addons/api/addon/generated/models/target.js index 4cc169731..d363a9d03 100644 --- a/addons/api/addon/generated/models/target.js +++ b/addons/api/addon/generated/models/target.js @@ -96,4 +96,16 @@ export default class GeneratedTargetModel extends BaseModel { "The default port that will be listened on by the client's local proxy.", }) default_client_port; + + @attr('string', { + description: 'The storage bucket associated with this target.', + isNestedAttribute: true, + }) + storage_bucket_id; + + @attr('boolean', { + description: 'Whether session recording is enabled on the target.', + isNestedAttribute: true, + }) + enable_session_recording; } diff --git a/addons/api/addon/models/base.js b/addons/api/addon/models/base.js index 6ba189d5e..3084f7bf4 100644 --- a/addons/api/addon/models/base.js +++ b/addons/api/addon/models/base.js @@ -57,7 +57,7 @@ export default class BaseModel extends Model { get scopeModel() { const id = this.scopeID; - return this.store.peekRecord('scope', id); + return id ? this.store.peekRecord('scope', id) : null; } // Ember seems to have a bug with its internals and can't correctly set diff --git a/addons/api/addon/models/channel-recording.js b/addons/api/addon/models/channel-recording.js new file mode 100644 index 000000000..eb35df41b --- /dev/null +++ b/addons/api/addon/models/channel-recording.js @@ -0,0 +1,25 @@ +import GeneratedChannelRecordingModel from '../generated/models/channel-recording'; +import { belongsTo } from '@ember-data/model'; + +export const MIME_TYPE_ASCIICAST = 'application/x-asciicast'; + +export default class ChannelRecordingModel extends GeneratedChannelRecordingModel { + @belongsTo('connection-recording', { async: false }) connection_recording; + + /** + * True if the channel supports asciicast. + * @type {boolean} + */ + get isAsciicast() { + return this.mime_types?.includes(MIME_TYPE_ASCIICAST) ?? false; + } + + /** + * Download the ascii cast recording for this channel. + * @returns {Promise} + */ + getAsciicast() { + const adapter = this.store.adapterFor('session-recording'); + return adapter.getAsciicast(this.id); + } +} diff --git a/addons/api/addon/models/connection-recording.js b/addons/api/addon/models/connection-recording.js new file mode 100644 index 000000000..ae0022191 --- /dev/null +++ b/addons/api/addon/models/connection-recording.js @@ -0,0 +1,7 @@ +import GeneratedConnectionRecordingModel from '../generated/models/connection-recording'; +import { belongsTo, hasMany } from '@ember-data/model'; + +export default class ConnectionRecordingModel extends GeneratedConnectionRecordingModel { + @belongsTo('session-recording', { async: false }) session_recording; + @hasMany('channel-recording', { async: false }) channel_recordings; +} diff --git a/addons/api/addon/models/session-recording.js b/addons/api/addon/models/session-recording.js new file mode 100644 index 000000000..85dece5e9 --- /dev/null +++ b/addons/api/addon/models/session-recording.js @@ -0,0 +1,63 @@ +import GeneratedSessionRecordingModel from '../generated/models/session-recording'; +import { hasMany } from '@ember-data/model'; +import { inject as service } from '@ember/service'; + +export const TYPE_SESSION_RECORDING_SSH = 'ssh'; +export const TYPES_SESSION_RECORDING = Object.freeze([ + TYPE_SESSION_RECORDING_SSH, +]); + +export const STATE_SESSION_RECORDING_STARTED = 'started'; +export const STATE_SESSION_RECORDING_AVAILABLE = 'available'; +export const STATE_SESSION_RECORDING_UNKNOWN = 'unknown'; + +export default class SessionRecordingModel extends GeneratedSessionRecordingModel { + @hasMany('connection-recording', { async: false }) connection_recordings; + + @service store; + + /** + * True if the session recording type is `ssh`. + * @type {boolean} + */ + get isSSH() { + return this.type === TYPE_SESSION_RECORDING_SSH; + } + + /** + * True if the session recording is an unknown type. + * @type {boolean} + */ + get isUnknown() { + return !TYPES_SESSION_RECORDING.includes(this.type); + } + + /** + * True if the session recording is in an available state. + * @type {boolean} + */ + get isAvailable() { + return this.state === STATE_SESSION_RECORDING_AVAILABLE; + } + + /** + * Returns scope name or scope id from the target that belongs to the session recording. + * @type {string} + */ + get targetScopeDisplayName() { + return ( + this.create_time_values?.target?.scope?.name || + this.create_time_values?.target?.scope?.id + ); + } + + /** + * Returns the user name or user id that belongs to the session recording. + * @type {string} + */ + get userDisplayName() { + return ( + this.create_time_values?.user?.name || this.create_time_values?.user?.id + ); + } +} diff --git a/addons/api/addon/models/storage-bucket.js b/addons/api/addon/models/storage-bucket.js new file mode 100644 index 000000000..1367e9802 --- /dev/null +++ b/addons/api/addon/models/storage-bucket.js @@ -0,0 +1,57 @@ +import GeneratedStorageBucketModel from '../generated/models/storage-bucket'; + +export const TYPE_STORAGE_BUCKET_PLUGIN = 'plugin'; +export const TYPES_STORAGE_BUCKET = Object.freeze([TYPE_STORAGE_BUCKET_PLUGIN]); + +export const TYPE_STORAGE_BUCKET_PLUGIN_AWS_S3 = 'aws'; +export const TYPES_STORAGE_BUCKET_PLUGIN = Object.freeze([ + TYPE_STORAGE_BUCKET_PLUGIN_AWS_S3, +]); + +export default class StorageBucketModel extends GeneratedStorageBucketModel { + /** + * True if the storage bucket is a plugin. + * @type {boolean} + */ + get isPlugin() { + return this.type === TYPE_STORAGE_BUCKET_PLUGIN; + } + + /** + * True if the storage bucket is an unknown type. + * @type {boolean} + */ + get isUnknown() { + return ( + !TYPES_STORAGE_BUCKET.includes(this.type) || + (this.isPlugin && + !TYPES_STORAGE_BUCKET_PLUGIN.includes(this.plugin?.name)) + ); + } + + /** + * True if the storage bucket plugin type is AWS. + * @type {boolean} + */ + get isAWS() { + return this.compositeType === TYPE_STORAGE_BUCKET_PLUGIN_AWS_S3; + } + + /** + * If the storage bucket is a plugin return `plugin.name`, + * otherwise return the storage bucket type. + * @type {string} + */ + get compositeType() { + if (this.isUnknown) return 'unknown'; + return this.plugin.name; + } + + /** + * Sets `type` to `plugin` and `plugin.name` to the specified type. + */ + set compositeType(type) { + this.type = TYPE_STORAGE_BUCKET_PLUGIN; + this.plugin = { name: type }; + } +} diff --git a/addons/api/addon/models/target.js b/addons/api/addon/models/target.js index 1d8073371..ec7b12e4e 100644 --- a/addons/api/addon/models/target.js +++ b/addons/api/addon/models/target.js @@ -317,6 +317,7 @@ export default class TargetModel extends GeneratedTargetModel { options ); } + /** * True if the target type is tcp. * @type {boolean} diff --git a/addons/api/addon/serializers/connection-recording.js b/addons/api/addon/serializers/connection-recording.js new file mode 100644 index 000000000..4f385ac2b --- /dev/null +++ b/addons/api/addon/serializers/connection-recording.js @@ -0,0 +1,10 @@ +import ApplicationSerializer from './application'; +import { EmbeddedRecordsMixin } from '@ember-data/serializer/rest'; + +export default class ConnectionSerializer extends ApplicationSerializer.extend( + EmbeddedRecordsMixin +) { + attrs = { + channel_recordings: { embedded: 'always' }, + }; +} diff --git a/addons/api/addon/serializers/session-recording.js b/addons/api/addon/serializers/session-recording.js new file mode 100644 index 000000000..0512b8667 --- /dev/null +++ b/addons/api/addon/serializers/session-recording.js @@ -0,0 +1,9 @@ +import ApplicationSerializer from './application'; +import { EmbeddedRecordsMixin } from '@ember-data/serializer/rest'; +export default class RecordingSerializer extends ApplicationSerializer.extend( + EmbeddedRecordsMixin +) { + attrs = { + connection_recordings: { embedded: 'always' }, + }; +} diff --git a/addons/api/addon/serializers/storage-bucket.js b/addons/api/addon/serializers/storage-bucket.js new file mode 100644 index 000000000..e4f928253 --- /dev/null +++ b/addons/api/addon/serializers/storage-bucket.js @@ -0,0 +1,22 @@ +import ApplicationSerializer from './application'; + +export default class StorageBucketSerializer extends ApplicationSerializer { + serialize(snapshot) { + const serialized = super.serialize(...arguments); + if (!snapshot.record.isNew) { + delete serialized.bucket_name; + delete serialized.bucket_prefix; + } + return serialized; + } + + normalize(typeClass, hash, ...rest) { + const normalizedHash = structuredClone(hash); + const normalized = super.normalize(typeClass, normalizedHash, ...rest); + + // Remove secret fields so we don't track them after creating/updating + normalized.data.attributes.access_key_id = null; + normalized.data.attributes.secret_access_key = null; + return normalized; + } +} diff --git a/addons/api/addon/serializers/target.js b/addons/api/addon/serializers/target.js index d79cb3799..25a952cfc 100644 --- a/addons/api/addon/serializers/target.js +++ b/addons/api/addon/serializers/target.js @@ -41,6 +41,16 @@ export default class TargetSerializer extends ApplicationSerializer { snapshot, injectedApplicationCredentialSourceIDs ); + + if (snapshot?.adapterOptions?.method === 'set-storage-bucket') { + serialized = this.serializeWithStorageBucket(snapshot); + } + if (snapshot?.adapterOptions?.method === 'remove-storage-bucket') { + return { + version: snapshot.attr('version'), + }; + } + return serialized; } @@ -91,4 +101,17 @@ export default class TargetSerializer extends ApplicationSerializer { injected_application_credential_source_ids, }; } + + /** + * Returns a payload containing only version and storage bucket id, + * rather than existing instances on the model. + * @param {Snapshot} snapshot + * @returns {object} + */ + serializeWithStorageBucket(snapshot) { + return { + version: snapshot.attr('version'), + storage_bucket_id: snapshot?.attr('storage_bucket_id'), + }; + } } diff --git a/addons/api/addon/transforms/duration.js b/addons/api/addon/transforms/duration.js new file mode 100644 index 000000000..153a46025 --- /dev/null +++ b/addons/api/addon/transforms/duration.js @@ -0,0 +1,40 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Transform from '@ember-data/serializer/transform'; + +export default class DurationTransform extends Transform { + /** + * Normalize a duration string in the format "3.0001s" to milliseconds. + * The backend returns a precision of nanoseconds but we round to whole + * milliseconds to avoid any issues with floating point math. + * @param serialized + * @returns {number} + */ + deserialize(serialized) { + if (!serialized) return 0; + const duration = serialized.split('s')[0]; + return Math.round(parseFloat(duration) * 1000); + } + + /** + * Serialize a duration in ms to a string in seconds. + * @param deserialized + * @returns {string} + */ + serialize(deserialized) { + if (!deserialized) return '0s'; + const durationInSeconds = deserialized / 1000; + + // We use `toLocaleString` to handle scenarios with very large numbers or + // very small numbers which will be represented as exponents. + // We force the max number of fraction digits to match the precision of the + // backend which is in nanoseconds. + return `${durationInSeconds.toLocaleString('fullwide', { + useGrouping: false, + maximumFractionDigits: 9, + })}s`; + } +} diff --git a/addons/api/app/abilities/channel-recording.js b/addons/api/app/abilities/channel-recording.js new file mode 100644 index 000000000..75aa2fd3c --- /dev/null +++ b/addons/api/app/abilities/channel-recording.js @@ -0,0 +1,6 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +export { default } from 'api/abilities/channel-recording'; diff --git a/addons/api/app/abilities/session-recording.js b/addons/api/app/abilities/session-recording.js new file mode 100644 index 000000000..07dbc7fe0 --- /dev/null +++ b/addons/api/app/abilities/session-recording.js @@ -0,0 +1,6 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +export { default } from 'api/abilities/session-recording'; diff --git a/addons/api/app/abilities/storage-bucket.js b/addons/api/app/abilities/storage-bucket.js new file mode 100644 index 000000000..ed343c8d4 --- /dev/null +++ b/addons/api/app/abilities/storage-bucket.js @@ -0,0 +1,6 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +export { default } from 'api/abilities/storage-bucket'; diff --git a/addons/api/app/adapters/session-recording.js b/addons/api/app/adapters/session-recording.js new file mode 100644 index 000000000..b8c3a26ce --- /dev/null +++ b/addons/api/app/adapters/session-recording.js @@ -0,0 +1 @@ +export { default } from 'api/adapters/session-recording'; diff --git a/addons/api/app/adapters/storage-bucket.js b/addons/api/app/adapters/storage-bucket.js new file mode 100644 index 000000000..ceadb4e71 --- /dev/null +++ b/addons/api/app/adapters/storage-bucket.js @@ -0,0 +1 @@ +export { default } from 'api/adapters/storage-bucket'; diff --git a/addons/api/app/models/channel-recording.js b/addons/api/app/models/channel-recording.js new file mode 100644 index 000000000..3567b0777 --- /dev/null +++ b/addons/api/app/models/channel-recording.js @@ -0,0 +1 @@ +export { default } from 'api/models/channel-recording'; diff --git a/addons/api/app/models/connection-recording.js b/addons/api/app/models/connection-recording.js new file mode 100644 index 000000000..bb13c4dbe --- /dev/null +++ b/addons/api/app/models/connection-recording.js @@ -0,0 +1 @@ +export { default } from 'api/models/connection-recording'; diff --git a/addons/api/app/models/session-recording.js b/addons/api/app/models/session-recording.js new file mode 100644 index 000000000..2e4b944fa --- /dev/null +++ b/addons/api/app/models/session-recording.js @@ -0,0 +1 @@ +export { default } from 'api/models/session-recording'; diff --git a/addons/api/app/models/storage-bucket.js b/addons/api/app/models/storage-bucket.js new file mode 100644 index 000000000..533d86097 --- /dev/null +++ b/addons/api/app/models/storage-bucket.js @@ -0,0 +1 @@ +export { default } from 'api/models/storage-bucket'; diff --git a/addons/api/app/serializers/connection-recording.js b/addons/api/app/serializers/connection-recording.js new file mode 100644 index 000000000..cf62e55de --- /dev/null +++ b/addons/api/app/serializers/connection-recording.js @@ -0,0 +1 @@ +export { default } from 'api/serializers/connection-recording'; diff --git a/addons/api/app/serializers/session-recording.js b/addons/api/app/serializers/session-recording.js new file mode 100644 index 000000000..57403d214 --- /dev/null +++ b/addons/api/app/serializers/session-recording.js @@ -0,0 +1 @@ +export { default } from 'api/serializers/session-recording'; diff --git a/addons/api/app/serializers/storage-bucket.js b/addons/api/app/serializers/storage-bucket.js new file mode 100644 index 000000000..b17b78a70 --- /dev/null +++ b/addons/api/app/serializers/storage-bucket.js @@ -0,0 +1 @@ +export { default } from 'api/serializers/storage-bucket'; diff --git a/addons/api/app/transforms/duration.js b/addons/api/app/transforms/duration.js new file mode 100644 index 000000000..3c3dd0cc5 --- /dev/null +++ b/addons/api/app/transforms/duration.js @@ -0,0 +1 @@ +export { default } from 'api/transforms/duration'; diff --git a/addons/api/mirage/config.js b/addons/api/mirage/config.js index 58e47471c..c3bdda3f7 100644 --- a/addons/api/mirage/config.js +++ b/addons/api/mirage/config.js @@ -14,6 +14,8 @@ import { targetHandler } from './route-handlers/target'; import { pickRandomStatusString } from './factories/session'; import initializeMockIPC from './scenarios/ipc'; import makeBooleanFilter from './helpers/bexpr-filter'; +import { faker } from '@faker-js/faker'; +import { asciicasts } from './data/asciicasts'; const isTesting = environmentConfig.environment === 'test'; @@ -33,6 +35,7 @@ export default function (mirageConfig) { function routes() { initializeMockIPC(this, environmentConfig); + this.passthrough(); // make this `http://localhost:8080`, for example, if your API is on a different server // this.urlPrefix = ''; @@ -649,8 +652,7 @@ function routes() { this.patch('/managed-groups/:id'); this.del('/managed-groups/:id'); - // worker - + // workers this.get( '/workers', ({ workers }, { queryParams: { scope_id: scopeId } }) => { @@ -671,6 +673,65 @@ function routes() { return workers.create(newWorker.attrs); }); + // storage-buckets + this.get( + '/storage-buckets', + ({ storageBuckets }, { queryParams: { scope_id: scopeId, recursive } }) => { + if (recursive && scopeId === 'global') { + return storageBuckets.all(); + } + return storageBuckets.where({ scopeId }); + } + ); + this.get('/storage-buckets/:id'); + this.del('/storage-buckets/:id'); + this.patch('/storage-buckets/:id'); + this.post( + '/storage-buckets', + function ({ storageBuckets }, { queryParams: { plugin_name } }) { + const attrs = this.normalizedRequestAttrs(); + if (plugin_name) { + attrs.type = 'plugin'; + attrs.plugin = { + name: plugin_name, + }; + } + + // Remove the secrets and add the hmac in the response + delete attrs.secrets; + attrs.secrets_hmac = faker.git.commitSha(); + + return storageBuckets.create(attrs); + } + ); + + // session recordings + this.get( + '/session-recordings', + ( + { sessionRecordings }, + { queryParams: { scope_id: scopeId, recursive } } + ) => { + if (recursive && scopeId === 'global') { + return sessionRecordings.all(); + } + return sessionRecordings.where({ scopeId }); + } + ); + this.get( + '/session-recordings/:idMethod', + async ({ sessionRecordings }, { params: { idMethod } }) => { + const id = idMethod.split(':')[0]; + const method = idMethod.split(':')[1]; + + if (method === 'download') { + return faker.helpers.arrayElement(asciicasts); + } else { + return sessionRecordings.find(id); + } + } + ); + /* Uncomment the following line and the Response import above * Then change the response code to simulate error responses. * this.get('/scopes', () => new Response(505)); diff --git a/addons/api/mirage/data/asciicasts.js b/addons/api/mirage/data/asciicasts.js new file mode 100644 index 000000000..478913dc7 --- /dev/null +++ b/addons/api/mirage/data/asciicasts.js @@ -0,0 +1,151 @@ +export const asciicast1 = `{"version": 2, "width": 124, "height": 36, "timestamp": 1670352594, "env": {"SHELL": "/bin/zsh", "TERM": "xterm-256color"}} +[2.138652, "o", "\\u001b[1m\\u001b[7m%\\u001b[27m\\u001b[1m\\u001b[0m \\r \\r\\u001b]2;ember-asciinema-player\\u0007\\u001b]1;..iinema-player\\u0007"] +[2.220877, "o", "\\r\\u001b[0m\\u001b[27m\\u001b[24m\\u001b[J\\u001b[33member-asciinema-player\\u001b[00m \\u001b[32mgit\\u001b[00m:\\u001b[36m(main*)\\u001b[00m $ \\u001b[K"] +[2.221019, "o", "\\u001b[?1h\\u001b="] +[2.221076, "o", "\\u001b[?2004h"] +[2.676444, "o", "y"] +[2.788653, "o", "\\bya"] +[2.868652, "o", "r"] +[3.001764, "o", "n"] +[3.167636, "o", " "] +[3.313668, "o", "t"] +[3.460527, "o", "e"] +[3.561983, "o", "s"] +[3.63678, "o", "t"] +[3.853437, "o", "\\u001b[?1l\\u001b>"] +[3.853473, "o", "\\u001b[?2004l\\r\\r\\n"] +[3.854604, "o", "\\u001b]2;yarn test\\u0007\\u001b]1;yarn\\u0007"] +[4.489178, "o", "\\u001b[2K"] +[4.48982, "o", "\\u001b[1G\\u001b[1myarn run v1.22.19\\u001b[22m\\r\\n"] +[4.555618, "o", "\\u001b[2K"] +[4.555649, "o", "\\u001b[1G\\u001b[2m$ npm-run-all lint\\u001b[22m\\r\\n"] +[5.345253, "o", "\\u001b[2K"] +[5.345509, "o", "\\u001b[1G\\u001b[2m$ npm-run-all --print-name --aggregate-output --continue-on-error --parallel \\"lint:!(fix)\\"\\u001b[22m\\r\\n"] +[7.225926, "o", "\\r\\n> ember-asciinema-player@0.0.0 lint:js ember-asciinema-player/package.json\\r\\n> eslint . --cache \\r\\n\\r\\n$ eslint . --cache\\r\\n"] +[8.398641, "o", "\\r\\n> ember-asciinema-player@0.0.0 lint:hbs ember-asciinema-player/package.json\\r\\n> ember-template-lint . \\r\\n\\r\\n$ ember-template-lint .\\r\\n\\r\\n"] +[8.418537, "o", "\\u001b[2K\\u001b[1G\\u001b[2m$ ember test\\u001b[22m\\r\\n"] +[12.256156, "o", "\\u001b[?25l"] +[12.256464, "o", "\\u001b[32m⠋\\u001b[39m \\u001b[32mBuilding\\u001b[39m"] +[12.2573, "o", "\\u001b[2K"] +[12.257386, "o", "\\u001b[1G\\u001b[?25h"] +[12.257689, "o", "Environment: test\\r\\n"] +[12.257857, "o", "\\u001b[?25l\\u001b[32m⠋\\u001b[39m \\u001b[32mBuilding\\u001b[39m"] +[12.539035, "o", "\\u001b[2K"] +[12.539144, "o", "\\u001b[1G"] +[12.539219, "o", "\\u001b[32m⠙\\u001b[39m \\u001b[32mbuilding... \\u001b[39m"] +[12.745202, "o", "\\u001b[2K\\u001b[1G"] +[12.745343, "o", "\\u001b[32m⠹\\u001b[39m \\u001b[32mbuilding... \\u001b[39m[Babel: @ember/test-helpers > applyPatches]"] +[12.825004, "o", "\\u001b[2K"] +[12.825031, "o", "\\u001b[1G"] +[12.825122, "o", "\\u001b[32m⠸\\u001b[39m \\u001b[32mbuilding... \\u001b[39m[Babel: ember-qunit > applyPatches]"] +[12.933588, "o", "\\u001b[2K\\u001b[1G"] +[12.933624, "o", "\\u001b[32m⠼\\u001b[39m \\u001b[32mbuilding... \\u001b[39m[Babel: ember-load-initializers > applyPatches]"] +[13.194738, "o", "\\u001b[2K\\u001b[1G"] +[13.195009, "o", "\\u001b[32m⠴\\u001b[39m \\u001b[32mbuilding... \\u001b[39m[Babel: ember-asciinema-player > applyPatches]"] +[13.307878, "o", "\\u001b[2K\\u001b[1G\\u001b[32m⠦\\u001b[39m \\u001b[32mbuilding... \\u001b[39m[Package /assets/vendor.js]"] +[13.750717, "o", "\\u001b[2K\\u001b[1G"] +[13.750755, "o", "\\u001b[32m⠧\\u001b[39m \\u001b[32mbuilding... \\u001b[39m[Concat: Test Support JS]"] +[13.869411, "o", "\\u001b[2K\\u001b[1G"] +[13.869438, "o", "\\u001b[32m⠇\\u001b[39m \\u001b[32mbuilding... \\u001b[39m[ember-auto-import-webpack]"] +[14.655374, "o", "\\u001b[2K\\u001b[1G"] +[14.655415, "o", "\\u001b[32m⠏\\u001b[39m \\u001b[32mbuilding... \\u001b[39m[ember-auto-import-webpack]"] +[14.73686, "o", "\\u001b[2K\\u001b[1G\\u001b[?25h"] +[14.738533, "o", "\\u001b[?25h"] +[14.738818, "o", "\\u001b[?25l\\u001b[32m⠋\\u001b[39m cleaning up"] +[14.739061, "o", "\\u001b[2K\\u001b[1G\\u001b[?25hcleaning up...\\r\\n"] +[14.739197, "o", "\\u001b[?25l\\u001b[32m⠋\\u001b[39m cleaning up"] +[14.907985, "o", "\\u001b[2K"] +[14.90808, "o", "\\u001b[1G\\u001b[?25h"] +[14.908128, "o", "\\u001b[32mBuilt project successfully.\\u001b[39m\\r\\n"] +[16.224632, "o", "ok 1 Chrome 108.0 - [2 ms] - ember-qunit: Ember.onerror validation: Ember.onerror is functioning properly\\r\\n"] +[16.346568, "o", "\\r\\n1..1\\r\\n# tests 1\\r\\n# pass 1\\r\\n# skip 0\\r\\n# todo 0\\r\\n# fail 0\\r\\n\\r\\n# ok\\r\\n"] +[16.403167, "o", "\\u001b[2K"] +[16.403388, "o", "\\u001b[1G✨ Done in 11.92s.\\r\\n"] +[16.413967, "o", "\\u001b[1m\\u001b[7m%\\u001b[27m\\u001b[1m\\u001b[0m \\r \\r"] +[16.414147, "o", "\\u001b]2;ember-asciinema-player\\u0007\\u001b]1;..iinema-player\\u0007"] +[16.528341, "o", "\\r\\u001b[0m\\u001b[27m\\u001b[24m\\u001b[J\\u001b[33member-asciinema-player\\u001b[00m \\u001b[32mgit\\u001b[00m:\\u001b[36m(main*)\\u001b[00m $ \\u001b[K"] +[16.528501, "o", "\\u001b[?1h\\u001b="] +[16.52863, "o", "\\u001b[?2004h"] +[19.078794, "o", "\\u001b[?2004l\\r\\r\\n"]`; + +export const asciicast2 = `{"version": 2, "width": 112, "height": 30, "timestamp": 1672793720, "env": {"SHELL": "/bin/bash", "TERM": "xterm-256color"}} +[0.036928, "o", "\\u001b[?2004h\\u001b[1;32m[\\u001b]0;johan@nixos: ~/src/hashicorp/boundary-enterprise\\u0007johan@nixos:~/src/hashicorp/boundary-enterprise]$\\u001b[0m "] +[2.827983, "o", "b"] +[2.9386, "o", "o"] +[2.97036, "o", "u"] +[3.032542, "o", "n"] +[3.108687, "o", "d"] +[3.246045, "o", "a"] +[3.340117, "o", "r"] +[3.841216, "o", "y"] +[4.314958, "o", " "] +[6.052102, "o", "u"] +[6.146486, "o", "s"] +[6.207341, "o", "e"] +[6.273087, "o", "r"] +[6.378873, "o", "s"] +[6.799687, "o", " "] +[7.08347, "o", "l"] +[7.159196, "o", "i"] +[7.249656, "o", "s"] +[7.352251, "o", "t"] +[7.450038, "o", " "] +[7.688195, "o", "-"] +[7.931818, "o", "r"] +[8.001542, "o", "e"] +[8.387771, "o", "c"] +[8.526339, "o", "u"] +[8.631438, "o", "r"] +[8.81899, "o", "s"] +[8.958184, "o", "i"] +[9.085688, "o", "v"] +[9.134885, "o", "e"] +[9.786868, "o", "\\r\\n"] +[9.788828, "o", "\\u001b[?2004l\\r"] +[10.086691, "o", "\\u001b[0m\\r\\nUser information:\\r\\n ID: u_anon\\r\\n Scope ID: global\\r\\n Version: 1\\r\\n Name: anonymous\\r\\n Description: The anonymous user matches any request, whether authenticated or not\\r\\n Authorized Actions:\\r\\n no-op\\r\\n read\\r\\n update\\r\\n delete\\r\\n add-accounts\\r\\n set-accounts\\r\\n remove-accounts\\r\\n\\r\\n ID: u_auth\\r\\n Scope ID: global\\r\\n Version: 1\\r\\n Name: authenticated\\r\\n Description: The authenticated user matches any user that has a valid token\\r\\n Authorized Actions:\\r\\n no-op\\r\\n read\\r\\n update\\r\\n delete\\r\\n add-accounts\\r\\n set-accounts\\r\\n remove-accounts\\r\\n\\r\\n ID: u_recovery\\r\\n Scope ID: global\\r\\n Version: 1\\r\\n Name: recovery\\r\\n Description: The recovery user is used for any request that was performed with the recovery KMS\\r\\n workflow\\r\\n Autho"] +[10.086736, "o", "rized Actions:\\r\\n no-op\\r\\n read\\r\\n update\\r\\n delete\\r\\n add-accounts\\r\\n set-accounts\\r\\n remove-accounts\\r\\n\\r\\n ID: u_1234567890\\r\\n Scope ID: global\\r\\n Version: 3\\r\\n Name: admin\\r\\n Description: Initial admin user within the \\"global\\" scope\\r\\n Primary Account ID: acctpw_1234567890\\r\\n Login Name: admin\\r\\n Authorized Actions:\\r\\n no-op\\r\\n read\\r\\n update\\r\\n delete\\r\\n add-accounts\\r\\n set-accounts\\r\\n remove-accounts\\r\\n\\r\\n ID: u_0987654321\\r\\n Scope ID: global\\r\\n Version: 3\\r\\n Name: user\\r\\n Description: Initial unprivileged user\\r\\n Primary Account ID: acctpw_0987654321\\r\\n Login Name: user\\r\\n Authorized Actions:\\r\\n no-op\\r\\n read\\r\\n update\\r\\n delete\\r\\n add-accounts\\r\\n set-accounts\\r\\n remove-accounts\\u001b[0m\\r\\n"] +[10.106453, "o", "\\u001b[?2004h\\u001b[1;32m[\\u001b]0;johan@nixos: ~/src/hashicorp/boundary-enterprise\\u0007johan@nixos:~/src/hashicorp/boundary-enterprise]$\\u001b[0m "] +[12.501496, "o", "b"] +[12.703134, "o", "o"] +[12.752198, "o", "u"] +[12.775329, "o", "n"] +[12.905261, "o", "d"] +[13.002373, "o", "a"] +[13.100111, "o", "r"] +[13.354779, "o", "y"] +[13.699827, "o", " "] +[13.998849, "o", "t"] +[14.139192, "o", "a"] +[14.19663, "o", "r"] +[14.392075, "o", "g"] +[14.489938, "o", "e"] +[14.579533, "o", "t"] +[14.674628, "o", "s"] +[14.830951, "o", " "] +[15.015311, "o", "l"] +[15.077147, "o", "i"] +[15.262221, "o", "s"] +[15.355571, "o", "t"] +[15.456597, "o", " "] +[15.91612, "o", "-"] +[16.264491, "o", "r"] +[16.351595, "o", "e"] +[16.798456, "o", "c"] +[16.891689, "o", "u"] +[16.997975, "o", "r"] +[17.176378, "o", "s"] +[17.290104, "o", "i"] +[17.434691, "o", "v"] +[17.482502, "o", "e"] +[18.393541, "o", "\\r\\n"] +[18.39558, "o", "\\u001b[?2004l\\r"] +[18.673452, "o", "\\u001b[0m\\r\\nTarget information:\\r\\n ID: ttcp_1234567890\\r\\n Scope ID: p_1234567890\\r\\n Version: 2\\r\\n Type: tcp\\r\\n Name: Generated target\\r\\n Description: Provides an initial target in Boundary\\r\\n Authorized Actions:\\r\\n no-op\\r\\n read\\r\\n update\\r\\n delete\\r\\n add-host-sources\\r\\n set-host-sources\\r\\n remove-host-sources\\r\\n add-credential-sources\\r\\n set-credential-sources\\r\\n remove-credential-sources\\r\\n authorize-session\\u001b[0m\\r\\n"] +[18.692528, "o", "\\u001b[?2004h\\u001b[1;32m[\\u001b]0;johan@nixos: ~/src/hashicorp/boundary-enterprise\\u0007johan@nixos:~/src/hashicorp/boundary-enterprise]$\\u001b[0m "] +[20.083966, "o", "e"] +[20.37301, "o", "x"] +[20.51445, "o", "i"] +[20.612885, "o", "t"] +[21.303896, "o", "\\r\\n"] +[21.307902, "o", "\\u001b[?2004l\\rexit\\r\\n"]`; + +export const asciicasts = [asciicast1, asciicast2]; diff --git a/addons/api/mirage/factories/channel-recording.js b/addons/api/mirage/factories/channel-recording.js new file mode 100644 index 000000000..75ad07dcc --- /dev/null +++ b/addons/api/mirage/factories/channel-recording.js @@ -0,0 +1,17 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import factory from '../generated/factories/channel-recording'; +import generateId from '../helpers/id'; +import { faker } from '@faker-js/faker'; + +export default factory.extend({ + id: () => generateId('chr_'), + start_time(i) { + return i % 3 === 2 ? null : faker.date.recent(3, this.end_time); + }, + end_time: (i) => (i % 3 === 2 ? null : faker.date.recent()), + duration: (i) => (i % 3 === 2 ? null : `${faker.datatype.number()}s`), +}); diff --git a/addons/api/mirage/factories/connection-recording.js b/addons/api/mirage/factories/connection-recording.js new file mode 100644 index 000000000..c8196a4b8 --- /dev/null +++ b/addons/api/mirage/factories/connection-recording.js @@ -0,0 +1,18 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import factory from '../generated/factories/connection-recording'; +import generateId from '../helpers/id'; +import { faker } from '@faker-js/faker'; + +export default factory.extend({ + id: () => generateId('cr_'), + connection_id: () => generateId('sc_'), + start_time(i) { + return i % 3 === 2 ? null : faker.date.recent(3, this.end_time); + }, + end_time: (i) => (i % 3 === 2 ? null : faker.date.recent()), + duration: (i) => (i % 3 === 2 ? null : `${faker.datatype.number()}s`), +}); diff --git a/addons/api/mirage/factories/scope.js b/addons/api/mirage/factories/scope.js index 1a73738ab..d94b84186 100644 --- a/addons/api/mirage/factories/scope.js +++ b/addons/api/mirage/factories/scope.js @@ -37,6 +37,12 @@ export default factory.extend({ collectionActions.workers = ['create:worker-led', 'list']; } + // Session recording resources are only available on global or org scope + if (this.type === 'global' || this.type === 'org') { + collectionActions['storage-buckets'] = ['create', 'list']; + collectionActions['session-recordings'] = ['list']; + } + return collectionActions; }, diff --git a/addons/api/mirage/factories/session-recording.js b/addons/api/mirage/factories/session-recording.js new file mode 100644 index 000000000..2612e9cc4 --- /dev/null +++ b/addons/api/mirage/factories/session-recording.js @@ -0,0 +1,103 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import factory from '../generated/factories/session-recording'; +import permissions from '../helpers/permissions'; +import generateId from '../helpers/id'; +import { faker } from '@faker-js/faker'; +import { trait } from 'miragejs'; +import { + TYPES_SESSION_RECORDING as types, + STATE_SESSION_RECORDING_STARTED, + STATE_SESSION_RECORDING_AVAILABLE, + STATE_SESSION_RECORDING_UNKNOWN, +} from 'api/models/session-recording'; + +const states = [ + STATE_SESSION_RECORDING_AVAILABLE, + STATE_SESSION_RECORDING_STARTED, + STATE_SESSION_RECORDING_UNKNOWN, +]; +const pickRandomElement = (arr) => faker.helpers.arrayElement(arr); +export default factory.extend({ + id: () => generateId('sr_'), + + // Cycle through available types + type: (i) => types[i % types.length], + state: (i) => states[i % states.length], + start_time() { + return this.state === STATE_SESSION_RECORDING_AVAILABLE + ? faker.date.recent(3, this.end_time) + : null; + }, + end_time() { + return this.state === STATE_SESSION_RECORDING_AVAILABLE + ? faker.date.recent(1, this.updated_time) + : null; + }, + duration() { + return this.state === STATE_SESSION_RECORDING_AVAILABLE + ? `${faker.datatype.number()}s` + : null; + }, + + authorized_actions: () => + permissions.authorizedActionsFor('session-recording') || [ + 'no-op', + 'read', + 'download', + ], + + withNonExistingUserAndTarget: trait({ + afterCreate(sessionRecording, server) { + const user = pickRandomElement(server.schema.users.all().models); + user.id = 'deleted_user'; + const session = pickRandomElement(server.schema.sessions.all().models); + session.id = 'deleted_session'; + const target = pickRandomElement( + server.schema.targets.where((target) => target.storage_bucket_id).models + ); + target.id = 'deleted_target'; + + sessionRecording.update({ + sessionId: session.id, + storage_bucket_id: target.storage_bucket_id, + create_time_values: { + user: user.attrs, + target: target.attrs, + }, + }); + }, + }), + + withExistingUserAndTarget: trait({ + afterCreate(sessionRecording, server) { + const user = pickRandomElement(server.schema.users.all().models); + const session = pickRandomElement(server.schema.sessions.all().models); + const target = pickRandomElement( + server.schema.targets.where((target) => target.storage_bucket_id).models + ); + + sessionRecording.update({ + sessionId: session.id, + storage_bucket_id: target.storage_bucket_id, + create_time_values: { + user: user.attrs, + target: target.attrs, + }, + }); + }, + }), + + withConnectionAndChannels: trait({ + afterCreate(session_recording, server) { + server + .createList('connection-recording', 3, { session_recording }) + .forEach((connection_recording) => { + server.createList('channel-recording', 3, { connection_recording }); + }); + }, + }), +}); diff --git a/addons/api/mirage/factories/storage-bucket.js b/addons/api/mirage/factories/storage-bucket.js new file mode 100644 index 000000000..3482d45d1 --- /dev/null +++ b/addons/api/mirage/factories/storage-bucket.js @@ -0,0 +1,57 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import factory from '../generated/factories/storage-bucket'; +import permissions from '../helpers/permissions'; +import { faker } from '@faker-js/faker'; +import generateId from '../helpers/id'; +import { + TYPES_STORAGE_BUCKET as types, + TYPES_STORAGE_BUCKET_PLUGIN, + TYPE_STORAGE_BUCKET_PLUGIN_AWS_S3, +} from 'api/models/storage-bucket'; + +// Represents known plugin types, except "foobar" which models the possibility +// of receiving an _unknown_ type, which the UI must handle gracefully. +const pluginTypes = [...TYPES_STORAGE_BUCKET_PLUGIN, 'foobar']; + +export default factory.extend({ + id: () => generateId('sb_'), + + // Cycle through available types + type: (i) => types[i % types.length], + + authorized_actions: () => + permissions.authorizedActionsFor('storage-bucket') || [ + 'no-op', + 'read', + 'update', + 'delete', + ], + + plugin: function (i) { + if (this.type === 'plugin') { + return { + id: `plugin-id-${i}`, + name: pluginTypes[i % pluginTypes.length], + description: faker.random.words(), + }; + } + }, + + attributes() { + switch (this.plugin?.name) { + case TYPE_STORAGE_BUCKET_PLUGIN_AWS_S3: + return { + region: `us-${faker.address.cardinalDirection()}-${faker.datatype.number( + { + max: 9, + } + )}`, + disable_credential_rotation: faker.datatype.boolean(), + }; + } + }, +}); diff --git a/addons/api/mirage/factories/target.js b/addons/api/mirage/factories/target.js index e664a1532..f09fdeb8c 100644 --- a/addons/api/mirage/factories/target.js +++ b/addons/api/mirage/factories/target.js @@ -8,7 +8,7 @@ import { trait } from 'miragejs'; import { faker } from '@faker-js/faker'; import permissions from '../helpers/permissions'; import generateId from '../helpers/id'; -import { TYPES_TARGET } from 'api/models/target'; +import { TYPES_TARGET, TYPE_TARGET_SSH } from 'api/models/target'; const randomBoolean = (chance = 0.5) => Math.random() < chance; const hostSetChance = 0.3; @@ -26,6 +26,8 @@ export default factory.extend({ 'remove-host-sources', 'add-credential-sources', 'remove-credential-sources', + 'set-storage-bucket', + 'remove-storage-bucket', ], id: () => generateId('t_'), @@ -66,6 +68,18 @@ export default factory.extend({ .where((credential) => credential.scopeId === target.scope.id) .models.filter(() => randomBoolean()) .map((cred) => cred.id); + + const randomlySelectedStorageBucket = faker.helpers.arrayElement( + server.schema.storageBuckets.all().models + ); + + if (randomlySelectedStorageBucket && target.type === TYPE_TARGET_SSH) { + target.update({ + storage_bucket_id: randomlySelectedStorageBucket.id, + enable_session_recording: true, + }); + } + target.update({ hostSets: randomlySelectedHostSets, brokeredCredentialSourceIds: [ diff --git a/addons/api/mirage/generated/factories/channel-recording.js b/addons/api/mirage/generated/factories/channel-recording.js new file mode 100644 index 000000000..27ea5539f --- /dev/null +++ b/addons/api/mirage/generated/factories/channel-recording.js @@ -0,0 +1,20 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { Factory } from 'miragejs'; +import { faker } from '@faker-js/faker'; +import { MIME_TYPE_ASCIICAST } from 'api/models/channel-recording'; + +/** + * GeneratedChannelRecordingModelFactory + */ +export default Factory.extend({ + bytes_up: () => + faker.random.numeric(faker.datatype.number({ min: 1, max: 12 })), + bytes_down: () => + faker.random.numeric(faker.datatype.number({ min: 1, max: 12 })), + errors: () => faker.datatype.number({ max: 10 }), + mime_types: () => [MIME_TYPE_ASCIICAST], +}); diff --git a/addons/api/mirage/generated/factories/connection-recording.js b/addons/api/mirage/generated/factories/connection-recording.js new file mode 100644 index 000000000..f0b50e501 --- /dev/null +++ b/addons/api/mirage/generated/factories/connection-recording.js @@ -0,0 +1,18 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { Factory } from 'miragejs'; +import { faker } from '@faker-js/faker'; + +/** + * GeneratedConnectionRecordingModelFactory + */ +export default Factory.extend({ + bytes_up: () => + faker.random.numeric(faker.datatype.number({ min: 1, max: 12 })), + bytes_down: () => + faker.random.numeric(faker.datatype.number({ min: 1, max: 12 })), + errors: () => faker.datatype.number({ max: 10 }), +}); diff --git a/addons/api/mirage/generated/factories/session-recording.js b/addons/api/mirage/generated/factories/session-recording.js new file mode 100644 index 000000000..5fc563d94 --- /dev/null +++ b/addons/api/mirage/generated/factories/session-recording.js @@ -0,0 +1,20 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { Factory } from 'miragejs'; +import { faker } from '@faker-js/faker'; + +/** + * GeneratedSessionRecordingModelFactory + */ +export default Factory.extend({ + bytes_up: () => + faker.random.numeric(faker.datatype.number({ min: 1, max: 12 })), + bytes_down: () => + faker.random.numeric(faker.datatype.number({ min: 1, max: 12 })), + errors: () => faker.datatype.number({ max: 10 }), + created_time: () => faker.date.recent(), + updated_time: () => faker.date.recent(), +}); diff --git a/addons/api/mirage/generated/factories/storage-bucket.js b/addons/api/mirage/generated/factories/storage-bucket.js new file mode 100644 index 000000000..82be1797b --- /dev/null +++ b/addons/api/mirage/generated/factories/storage-bucket.js @@ -0,0 +1,21 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { Factory } from 'miragejs'; +import { faker } from '@faker-js/faker'; + +/** + * GeneratedStorageBucketModelFactory + */ +export default Factory.extend({ + name: () => faker.random.words(), + description: () => faker.random.words(), + created_time: () => faker.date.recent(), + updated_time: () => faker.date.recent(), + version: () => faker.datatype.number(), + bucket_name: () => faker.internet.domainWord(), + bucket_prefix: () => faker.system.directoryPath(), + worker_filter: () => faker.random.words(), +}); diff --git a/addons/api/mirage/models/channel-recording.js b/addons/api/mirage/models/channel-recording.js new file mode 100644 index 000000000..857e81844 --- /dev/null +++ b/addons/api/mirage/models/channel-recording.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { belongsTo, Model } from 'miragejs'; + +export default Model.extend({ + connection_recording: belongsTo(), +}); diff --git a/addons/api/mirage/models/connection-recording.js b/addons/api/mirage/models/connection-recording.js new file mode 100644 index 000000000..eeb2f98de --- /dev/null +++ b/addons/api/mirage/models/connection-recording.js @@ -0,0 +1,11 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { belongsTo, hasMany, Model } from 'miragejs'; + +export default Model.extend({ + session_recording: belongsTo(), + channel_recordings: hasMany(), +}); diff --git a/addons/api/mirage/models/session-recording.js b/addons/api/mirage/models/session-recording.js new file mode 100644 index 000000000..924431085 --- /dev/null +++ b/addons/api/mirage/models/session-recording.js @@ -0,0 +1,11 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { belongsTo, hasMany, Model } from 'miragejs'; + +export default Model.extend({ + scope: belongsTo(), + connection_recordings: hasMany(), +}); diff --git a/addons/api/mirage/models/storage-bucket.js b/addons/api/mirage/models/storage-bucket.js new file mode 100644 index 000000000..a68d129c9 --- /dev/null +++ b/addons/api/mirage/models/storage-bucket.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { belongsTo } from 'miragejs'; +import Model from './base'; +export default Model.extend({ + scope: belongsTo(), +}); diff --git a/addons/api/mirage/route-handlers/target.js b/addons/api/mirage/route-handlers/target.js index b26c59b7e..ae2f1212e 100644 --- a/addons/api/mirage/route-handlers/target.js +++ b/addons/api/mirage/route-handlers/target.js @@ -100,5 +100,6 @@ export function targetHandler({ targets }, { params: { idMethod } }) { removedCredentialSources; } } + return target.update(updatedAttrs); } diff --git a/addons/api/mirage/scenarios/default.js b/addons/api/mirage/scenarios/default.js index 17a4fd90c..893a2fd0f 100644 --- a/addons/api/mirage/scenarios/default.js +++ b/addons/api/mirage/scenarios/default.js @@ -94,6 +94,10 @@ export default function (server) { role.update({ managedGroups }); }); + // Storage Buckets + server.createList('storage-bucket', 3, { scope: globalScope }); + server.createList('storage-bucket', 2, { scope: orgScope }); + // Other resources server.schema.scopes.where({ type: 'project' }).models.forEach((scope) => { server.createList('host-catalog', 8, { scope }, 'withChildren'); @@ -109,4 +113,26 @@ export default function (server) { // Workers server.createList('worker', 3, { scope: globalScope }); + + // Session Recordings + server.createList( + 'session-recording', + 3, + { scope: globalScope }, + 'withConnectionAndChannels', + 'withExistingUserAndTarget' + ); + server.create( + 'session-recording', + { scope: globalScope }, + 'withConnectionAndChannels', + 'withNonExistingUserAndTarget' + ); + server.createList( + 'session-recording', + 2, + { scope: orgScope }, + 'withConnectionAndChannels', + 'withExistingUserAndTarget' + ); } diff --git a/addons/api/mirage/serializers/channel-recording.js b/addons/api/mirage/serializers/channel-recording.js new file mode 100644 index 000000000..2fdc38fd8 --- /dev/null +++ b/addons/api/mirage/serializers/channel-recording.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import ApplicationSerializer from './application'; + +export default ApplicationSerializer.extend({ + modelName: 'channel-recording', +}); diff --git a/addons/api/mirage/serializers/connection-recording.js b/addons/api/mirage/serializers/connection-recording.js new file mode 100644 index 000000000..273b5a45b --- /dev/null +++ b/addons/api/mirage/serializers/connection-recording.js @@ -0,0 +1,25 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import ApplicationSerializer from './application'; +import ChannelSerializer from './channel-recording'; + +export default ApplicationSerializer.extend({ + modelName: 'connection-recording', + _hashForModel(model) { + const json = ApplicationSerializer.prototype._hashForModel.apply( + this, + arguments + ); + const { channelRecordings } = this.schema; + if (model.channelRecordingIds) { + json.channel_recordings = model.channelRecordingIds.map((id) => { + const channel = channelRecordings.find(id); + return ChannelSerializer.prototype._hashForModel.apply(this, [channel]); + }); + } + return json; + }, +}); diff --git a/addons/api/mirage/serializers/session-recording.js b/addons/api/mirage/serializers/session-recording.js new file mode 100644 index 000000000..dcd1672fe --- /dev/null +++ b/addons/api/mirage/serializers/session-recording.js @@ -0,0 +1,46 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import ApplicationSerializer from './application'; +import ConnectionRecordingSerializer from './connection-recording'; +import ScopeSerializer from './scope'; + +export default ApplicationSerializer.extend({ + modelName: 'session-recording', + _hashForModel(model) { + const json = ApplicationSerializer.prototype._hashForModel.apply( + this, + arguments + ); + const { connectionRecordings, scopes } = this.schema; + + // Manually map our models to the actual embedded json representation + if (model.connectionRecordingIds) { + json.connection_recordings = model.connectionRecordingIds.map((id) => { + const connection = connectionRecordings.find(id); + return ConnectionRecordingSerializer.prototype._hashForModel.apply( + this, + [connection] + ); + }); + } + const userScopeId = model.create_time_values?.user?.scopeId; + if (userScopeId) { + const scope = scopes.find(userScopeId); + json.create_time_values.user.scope = + ScopeSerializer.prototype._hashForModel.apply(this, [scope]); + } + const targetScopeId = model.create_time_values?.target?.scopeId; + if (targetScopeId) { + const scope = scopes.find(targetScopeId); + json.create_time_values.target.scope = + ScopeSerializer.prototype._hashForModel.apply(this, [scope]); + } + delete json.create_time_values?.target?.storage_bucket_id; + + json.session_id = model.sessionId; + return json; + }, +}); diff --git a/addons/api/mirage/serializers/storage-bucket.js b/addons/api/mirage/serializers/storage-bucket.js new file mode 100644 index 000000000..ca9f87933 --- /dev/null +++ b/addons/api/mirage/serializers/storage-bucket.js @@ -0,0 +1,9 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import ApplicationSerializer from './application'; +export default ApplicationSerializer.extend({ + modelName: 'storage-bucket', +}); diff --git a/addons/api/tests/unit/abilities/channel-recording-test.js b/addons/api/tests/unit/abilities/channel-recording-test.js new file mode 100644 index 000000000..335d85d9d --- /dev/null +++ b/addons/api/tests/unit/abilities/channel-recording-test.js @@ -0,0 +1,67 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import { MIME_TYPE_ASCIICAST } from 'api/models/channel-recording'; +import { TYPE_SESSION_RECORDING_SSH } from 'api/models/session-recording'; + +module('Unit | Ability | channel-recording', function (hooks) { + setupTest(hooks); + + let canService; + let store; + + hooks.beforeEach(function () { + canService = this.owner.lookup('service:can'); + store = this.owner.lookup('service:store'); + }); + + test('it exists', function (assert) { + const ability = this.owner.lookup('ability:channel-recording'); + assert.ok(ability); + }); + + test('it can download asciicast', function (assert) { + assert.expect(1); + + const sessionRecordingModel = store.createRecord('session-recording', { + authorized_actions: ['download'], + type: TYPE_SESSION_RECORDING_SSH, + }); + const connectionRecordingModel = store.createRecord( + 'connection-recording', + { + session_recording: sessionRecordingModel, + } + ); + const channelRecordingModel = store.createRecord('channel-recording', { + mime_types: [MIME_TYPE_ASCIICAST], + connection_recording: connectionRecordingModel, + }); + + assert.true( + canService.can('getAsciicast channel-recording', channelRecordingModel) + ); + }); + + test('it cannot download asciicast without the correct mime type', function (assert) { + assert.expect(1); + + const sessionRecordingModel = store.createRecord('session-recording', { + authorized_actions: ['download'], + type: TYPE_SESSION_RECORDING_SSH, + }); + const connectionRecordingModel = store.createRecord( + 'connection-recording', + { + session_recording: sessionRecordingModel, + } + ); + const channelRecordingModel = store.createRecord('channel-recording', { + mime_types: ['unknown'], + connection_recording: connectionRecordingModel, + }); + + assert.false( + canService.can('getAsciicast channel-recording', channelRecordingModel) + ); + }); +}); diff --git a/addons/api/tests/unit/abilities/session-recording-test.js b/addons/api/tests/unit/abilities/session-recording-test.js new file mode 100644 index 000000000..5e258a323 --- /dev/null +++ b/addons/api/tests/unit/abilities/session-recording-test.js @@ -0,0 +1,49 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Ability | session-recording', function (hooks) { + setupTest(hooks); + + let canService; + let store; + + hooks.beforeEach(function () { + canService = this.owner.lookup('service:can'); + store = this.owner.lookup('service:store'); + }); + + test('it exists', function (assert) { + const ability = this.owner.lookup('ability:session-recording'); + assert.ok(ability); + }); + + test('can download when a recording has download authorization', function (assert) { + assert.expect(2); + + const recordingWithAuthorizedAction = store.createRecord( + 'session-recording', + { + authorized_actions: ['download'], + } + ); + const recordingWithoutAuthorizedAction = store.createRecord( + 'session-recording', + { + authorized_actions: [], + } + ); + + assert.true( + canService.can( + 'download session-recording', + recordingWithAuthorizedAction + ) + ); + assert.false( + canService.can( + 'download session-recording', + recordingWithoutAuthorizedAction + ) + ); + }); +}); diff --git a/addons/api/tests/unit/abilities/target-test.js b/addons/api/tests/unit/abilities/target-test.js index 93ea738b0..b5303504d 100644 --- a/addons/api/tests/unit/abilities/target-test.js +++ b/addons/api/tests/unit/abilities/target-test.js @@ -9,74 +9,90 @@ import { setupTest } from 'ember-qunit'; module('Unit | Abilities | Target', function (hooks) { setupTest(hooks); + let canService; + + hooks.beforeEach(function () { + canService = this.owner.lookup('service:can'); + }); + test('it reflects when a given target may connect based on authorized_actions', function (assert) { assert.expect(2); - const service = this.owner.lookup('service:can'); const model = { authorized_actions: ['authorize-session'], }; - assert.ok(service.can('connect target', model)); + assert.true(canService.can('connect target', model)); model.authorized_actions = []; - assert.notOk(service.can('connect target', model)); + assert.false(canService.can('connect target', model)); }); test('it reflects when a given target may add host sources', function (assert) { assert.expect(2); - const service = this.owner.lookup('service:can'); const model = { authorized_actions: ['add-host-sources'], }; - assert.ok(service.can('addHostSources target', model)); + assert.true(canService.can('addHostSources target', model)); model.authorized_actions = []; - assert.notOk(service.can('addHostSources target', model)); + assert.false(canService.can('addHostSources target', model)); }); test('it reflects when a given target may remove host sources', function (assert) { assert.expect(2); - const service = this.owner.lookup('service:can'); const model = { authorized_actions: ['remove-host-sources'], }; - assert.ok(service.can('removeHostSources target', model)); + assert.true(canService.can('removeHostSources target', model)); model.authorized_actions = []; - assert.notOk(service.can('removeHostSources target', model)); + assert.false(canService.can('removeHostSources target', model)); }); test('it reflects when a given target may add brokered credential sources', function (assert) { assert.expect(2); - const service = this.owner.lookup('service:can'); const model = { authorized_actions: ['add-credential-sources'], }; - assert.ok(service.can('addBrokeredCredentialSources target', model)); + assert.true(canService.can('addBrokeredCredentialSources target', model)); model.authorized_actions = []; - assert.notOk(service.can('addBrokeredCredentialSources target', model)); + assert.false(canService.can('addBrokeredCredentialSources target', model)); }); test('it reflects when a given target may remove credential sources', function (assert) { assert.expect(2); - const service = this.owner.lookup('service:can'); const model = { authorized_actions: ['remove-credential-sources'], }; - assert.ok(service.can('removeCredentialSources target', model)); + assert.true(canService.can('removeCredentialSources target', model)); model.authorized_actions = []; - assert.notOk(service.can('removeCredentialSources target', model)); + assert.false(canService.can('removeCredentialSources target', model)); }); test('it reflects when a given ssh target may add injected application credential sources', function (assert) { - assert.expect(2); - const service = this.owner.lookup('service:can'); + assert.expect(3); const model = { authorized_actions: ['add-credential-sources'], isSSH: true, }; - assert.ok( - service.can('addInjectedApplicationCredentialSources target', model) + const modelWithoutAuthorizedActions = { + authorized_actions: [], + isSSH: true, + }; + const modelWithoutSSHType = { + authorized_actions: ['add-credential-sources'], + }; + + assert.true( + canService.can('addInjectedApplicationCredentialSources target', model) ); - model.authorized_actions = []; - assert.notOk( - service.can('addInjectedApplicationCredentialSources target', model) + assert.false( + canService.can( + 'addInjectedApplicationCredentialSources target', + modelWithoutAuthorizedActions + ) + ); + assert.false( + canService.can( + 'addInjectedApplicationCredentialSources target', + modelWithoutSSHType + ) ); }); }); diff --git a/addons/api/tests/unit/adapters/session-recording-test.js b/addons/api/tests/unit/adapters/session-recording-test.js new file mode 100644 index 000000000..14696834e --- /dev/null +++ b/addons/api/tests/unit/adapters/session-recording-test.js @@ -0,0 +1,42 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Adapter | session recording', function (hooks) { + setupTest(hooks); + + test('it generates correct URLs for downloading asciicast', function (assert) { + assert.expect(1); + const mockSnapshot = { + adapterOptions: { + method: 'download', + }, + }; + const queryObject = { + mime_type: 'mimeType', + }; + const adapter = this.owner.lookup('adapter:session-recording'); + const createRecordURL = adapter.buildURL( + 'session-recording', + 'sr_id', + mockSnapshot, + 'findRecord', + queryObject + ); + assert.strictEqual( + createRecordURL, + '/v1/session-recordings/sr_id:download?mime_type=mimeType' + ); + }); + + test('it generates correct URLs for retrieving session-recordings', function (assert) { + assert.expect(1); + const adapter = this.owner.lookup('adapter:session-recording'); + const createRecordURL = adapter.buildURL( + 'session-recording', + 'sr_id', + {}, + 'findRecord' + ); + assert.strictEqual(createRecordURL, '/v1/session-recordings/sr_id'); + }); +}); diff --git a/addons/api/tests/unit/adapters/storage-bucket-test.js b/addons/api/tests/unit/adapters/storage-bucket-test.js new file mode 100644 index 000000000..dcfdfa83f --- /dev/null +++ b/addons/api/tests/unit/adapters/storage-bucket-test.js @@ -0,0 +1,30 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Adapter | storage bucket', function (hooks) { + setupTest(hooks); + + test('it generates correct createRecord URLs for storage buckets with extra query parameter ?plugin_name', function (assert) { + assert.expect(1); + const scopeID = 'o_1'; + const mockSnapshot = { + adapterOptions: { + scopeID, + }, + attr() { + return { name: 'aws_s3' }; + }, + }; + const adapter = this.owner.lookup('adapter:storage-bucket'); + const createRecordURL = adapter.buildURL( + 'storage-bucket', + null, + mockSnapshot, + 'createRecord' + ); + assert.strictEqual( + createRecordURL, + '/v1/storage-buckets?plugin_name=aws_s3' + ); + }); +}); diff --git a/addons/api/tests/unit/models/channel-recording-test.js b/addons/api/tests/unit/models/channel-recording-test.js new file mode 100644 index 000000000..7ee3ced5b --- /dev/null +++ b/addons/api/tests/unit/models/channel-recording-test.js @@ -0,0 +1,39 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import { MIME_TYPE_ASCIICAST } from 'api/models/channel-recording'; + +module('Unit | Model | channel-recording', function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let store = this.owner.lookup('service:store'); + let model = store.createRecord('channel-recording', {}); + assert.ok(model); + }); + + test('it has isAsciicast property and returns the expected values', async function (assert) { + assert.expect(2); + const store = this.owner.lookup('service:store'); + const modelA = store.createRecord('channel-recording', { + mime_types: [MIME_TYPE_ASCIICAST], + }); + const modelB = store.createRecord('channel-recording', { + mime_types: ['random'], + }); + assert.true(modelA.isAsciicast); + assert.false(modelB.isAsciicast); + }); + + test('it has isAsciicast property and returns the expected values when mime_types are not present', async function (assert) { + assert.expect(2); + const store = this.owner.lookup('service:store'); + const modelA = store.createRecord('channel-recording', { + mime_types: undefined, + }); + const modelB = store.createRecord('channel-recording', { + mime_types: [], + }); + assert.false(modelA.isAsciicast); + assert.false(modelB.isAsciicast); + }); +}); diff --git a/addons/api/tests/unit/models/connection-recording-test.js b/addons/api/tests/unit/models/connection-recording-test.js new file mode 100644 index 000000000..30325bf4c --- /dev/null +++ b/addons/api/tests/unit/models/connection-recording-test.js @@ -0,0 +1,23 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Model | connection-recording', function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let store = this.owner.lookup('service:store'); + let model = store.createRecord('connection-recording', {}); + assert.ok(model); + }); + + test('it has channels as a relationship', async function (assert) { + assert.expect(1); + const store = this.owner.lookup('service:store'); + const model = store.createRecord('connection-recording', { + channel_recordings: [store.createRecord('channel-recording')], + }); + + const channel_recordings = await model.channel_recordings; + assert.strictEqual(channel_recordings.length, 1); + }); +}); diff --git a/addons/api/tests/unit/models/session-recording-test.js b/addons/api/tests/unit/models/session-recording-test.js new file mode 100644 index 000000000..1751792b0 --- /dev/null +++ b/addons/api/tests/unit/models/session-recording-test.js @@ -0,0 +1,132 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import { TYPE_SESSION_RECORDING_SSH } from 'api/models/session-recording'; +import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; +import { + STATE_SESSION_RECORDING_AVAILABLE, + STATE_SESSION_RECORDING_STARTED, + STATE_SESSION_RECORDING_UNKNOWN, +} from 'api/models/session-recording'; + +module('Unit | Model | session-recording', function (hooks) { + setupTest(hooks); + setupMirage(hooks); + + test('it exists', function (assert) { + let store = this.owner.lookup('service:store'); + let model = store.createRecord('session-recording', {}); + assert.ok(model); + }); + + test('it has isUnknown property and returns the expected values', async function (assert) { + assert.expect(2); + const store = this.owner.lookup('service:store'); + const modelA = store.createRecord('session-recording', { + type: TYPE_SESSION_RECORDING_SSH, + }); + const modelB = store.createRecord('session-recording', { + type: 'no-such-type', + }); + assert.false(modelA.isUnknown); + assert.true(modelB.isUnknown); + }); + + test('it has isSSH property and returns the expected values', async function (assert) { + assert.expect(2); + const store = this.owner.lookup('service:store'); + const modelA = store.createRecord('session-recording', { + type: TYPE_SESSION_RECORDING_SSH, + }); + const modelB = store.createRecord('session-recording', { + type: 'random', + }); + assert.true(modelA.isSSH); + assert.false(modelB.isSSH); + }); + + test('it has isAvailable property and returns the expected values', async function (assert) { + assert.expect(3); + const store = this.owner.lookup('service:store'); + const modelA = store.createRecord('session-recording', { + state: STATE_SESSION_RECORDING_AVAILABLE, + }); + const modelB = store.createRecord('session-recording', { + state: STATE_SESSION_RECORDING_STARTED, + }); + const modelC = store.createRecord('session-recording', { + state: STATE_SESSION_RECORDING_UNKNOWN, + }); + assert.true(modelA.isAvailable); + assert.false(modelB.isAvailable); + assert.false(modelC.isAvailable); + }); + + test('it has connections as a relationship', async function (assert) { + assert.expect(1); + const store = this.owner.lookup('service:store'); + const model = store.createRecord('session-recording', { + type: TYPE_SESSION_RECORDING_SSH, + connection_recordings: [store.createRecord('connection-recording')], + }); + + const connection_recordings = await model.connection_recordings; + assert.strictEqual(connection_recordings.length, 1); + }); + + test('it has targetScopeDisplayName and returns the expected values', async function (assert) { + assert.expect(2); + const store = this.owner.lookup('service:store'); + const fakeName = 'Scope name'; + const fakeId = 's_123456789'; + + const modelA = store.createRecord('session-recording', { + create_time_values: { + target: { + scope: { + name: fakeName, + id: fakeId, + }, + }, + }, + }); + const modelB = store.createRecord('session-recording', { + create_time_values: { + target: { + scope: { + id: fakeId, + }, + }, + }, + }); + + assert.strictEqual(modelA.targetScopeDisplayName, fakeName); + assert.strictEqual(modelB.targetScopeDisplayName, fakeId); + }); + + test('it has userDisplayName and returns the expected values', async function (assert) { + assert.expect(2); + const store = this.owner.lookup('service:store'); + const fakeUserName = 'User name'; + const fakeUserId = 'userid'; + + const userModelA = store.createRecord('session-recording', { + create_time_values: { + user: { + name: fakeUserName, + id: fakeUserId, + }, + }, + }); + + const userModelB = store.createRecord('session-recording', { + create_time_values: { + user: { + id: fakeUserId, + }, + }, + }); + + assert.strictEqual(userModelA.userDisplayName, fakeUserName); + assert.strictEqual(userModelB.userDisplayName, fakeUserId); + }); +}); diff --git a/addons/api/tests/unit/models/storage-bucket-test.js b/addons/api/tests/unit/models/storage-bucket-test.js new file mode 100644 index 000000000..aff89dd3c --- /dev/null +++ b/addons/api/tests/unit/models/storage-bucket-test.js @@ -0,0 +1,80 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import { TYPE_STORAGE_BUCKET_PLUGIN_AWS_S3 } from 'api/models/storage-bucket'; + +module('Unit | Model | storage bucket', function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let store = this.owner.lookup('service:store'); + let model = store.createRecord('storage-bucket', {}); + assert.ok(model); + }); + + test('it has the isPlugin property and returns the expected values', async function (assert) { + assert.expect(1); + const store = this.owner.lookup('service:store'); + const modelPlugin = store.createRecord('storage-bucket', { + type: 'plugin', + plugin: { + name: TYPE_STORAGE_BUCKET_PLUGIN_AWS_S3, + }, + }); + assert.true(modelPlugin.isPlugin); + }); + + test('it has isUnknown property and returns the expected values', async function (assert) { + assert.expect(2); + const store = this.owner.lookup('service:store'); + const modelA = store.createRecord('storage-bucket', { + compositeType: TYPE_STORAGE_BUCKET_PLUGIN_AWS_S3, + }); + const modelB = store.createRecord('storage-bucket', { + compositeType: 'no-such-type', + }); + assert.false(modelA.isUnknown); + assert.true(modelB.isUnknown); + }); + + test('it has isAWS property and returns the expected values', async function (assert) { + assert.expect(2); + const store = this.owner.lookup('service:store'); + const modelAws = store.createRecord('storage-bucket', { + type: 'plugin', + plugin: { name: TYPE_STORAGE_BUCKET_PLUGIN_AWS_S3 }, + }); + const modelRandom = store.createRecord('storage-bucket', { + plugin: { name: 'random' }, + }); + assert.true(modelAws.isAWS); + assert.false(modelRandom.isAWS); + }); + + test('get compositeType returns expected values', async function (assert) { + assert.expect(2); + const store = this.owner.lookup('service:store'); + const modelA = store.createRecord('storage-bucket', { + type: 'plugin', + plugin: { name: TYPE_STORAGE_BUCKET_PLUGIN_AWS_S3 }, + }); + const modelB = store.createRecord('storage-bucket', { + type: 'plugin', + plugin: { name: 'no-such-type' }, + }); + assert.strictEqual(modelA.compositeType, TYPE_STORAGE_BUCKET_PLUGIN_AWS_S3); + assert.strictEqual(modelB.compositeType, 'unknown'); + }); + + test('set compositeType sets expected values', async function (assert) { + assert.expect(2); + const store = this.owner.lookup('service:store'); + const modelPlugin = store.createRecord('storage-bucket', { + compositeType: TYPE_STORAGE_BUCKET_PLUGIN_AWS_S3, + }); + assert.strictEqual(modelPlugin.type, 'plugin'); + assert.strictEqual( + modelPlugin.plugin.name, + TYPE_STORAGE_BUCKET_PLUGIN_AWS_S3 + ); + }); +}); diff --git a/addons/api/tests/unit/serializers/connection-recording-test.js b/addons/api/tests/unit/serializers/connection-recording-test.js new file mode 100644 index 000000000..9a72a2f69 --- /dev/null +++ b/addons/api/tests/unit/serializers/connection-recording-test.js @@ -0,0 +1,23 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Serializer | connection-recording-recording', function (hooks) { + setupTest(hooks); + + // Replace this with your real tests. + test('it exists', function (assert) { + let store = this.owner.lookup('service:store'); + let serializer = store.serializerFor('connection-recording'); + + assert.ok(serializer); + }); + + test('it serializes records', function (assert) { + let store = this.owner.lookup('service:store'); + let record = store.createRecord('connection-recording', {}); + + let serializedRecord = record.serialize(); + + assert.ok(serializedRecord); + }); +}); diff --git a/addons/api/tests/unit/serializers/session-recording-test.js b/addons/api/tests/unit/serializers/session-recording-test.js new file mode 100644 index 000000000..b1073919a --- /dev/null +++ b/addons/api/tests/unit/serializers/session-recording-test.js @@ -0,0 +1,23 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Serializer | session-recording', function (hooks) { + setupTest(hooks); + + // Replace this with your real tests. + test('it exists', function (assert) { + let store = this.owner.lookup('service:store'); + let serializer = store.serializerFor('session-recording'); + + assert.ok(serializer); + }); + + test('it serializes records', function (assert) { + let store = this.owner.lookup('service:store'); + let record = store.createRecord('session-recording', {}); + + let serializedRecord = record.serialize(); + + assert.ok(serializedRecord); + }); +}); diff --git a/addons/api/tests/unit/serializers/storage-bucket-test.js b/addons/api/tests/unit/serializers/storage-bucket-test.js new file mode 100644 index 000000000..5cb1406e4 --- /dev/null +++ b/addons/api/tests/unit/serializers/storage-bucket-test.js @@ -0,0 +1,152 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import { + TYPE_STORAGE_BUCKET_PLUGIN_AWS_S3, + TYPE_STORAGE_BUCKET_PLUGIN, +} from 'api/models/storage-bucket'; + +module('Unit | Serializer | storage bucket', function (hooks) { + setupTest(hooks); + + test('it serializes records', function (assert) { + let store = this.owner.lookup('service:store'); + let record = store.createRecord('storage-bucket', {}); + + let serializedRecord = record.serialize(); + + assert.ok(serializedRecord); + }); + + test('it serializes a new aws plugin as expected', async function (assert) { + assert.expect(1); + const store = this.owner.lookup('service:store'); + const record = store.createRecord('storage-bucket', { + compositeType: TYPE_STORAGE_BUCKET_PLUGIN_AWS_S3, + name: 'AWS', + description: 'this has an aws plugin', + bucket_name: 'bucketname', + bucket_prefix: 'bucketprefix', + worker_filter: 'workerfilter', + region: 'eu-west-1', + access_key_id: 'foobars', + secret_access_key: 'testing', + secrets_hmac: 'hmac', + disable_credential_rotation: true, + }); + const expectedResult = { + name: 'AWS', + description: 'this has an aws plugin', + type: TYPE_STORAGE_BUCKET_PLUGIN, + bucket_name: 'bucketname', + bucket_prefix: 'bucketprefix', + worker_filter: 'workerfilter', + attributes: { + region: 'eu-west-1', + disable_credential_rotation: true, + }, + secrets: { + access_key_id: 'foobars', + secret_access_key: 'testing', + }, + }; + assert.deepEqual(record.serialize(), expectedResult); + }); + + test('it serializes when updating correctly', async function (assert) { + assert.expect(1); + const store = this.owner.lookup('service:store'); + const serializer = store.serializerFor('storage-bucket'); + store.push({ + data: { + id: '1', + type: 'storage-bucket', + attributes: { + type: TYPE_STORAGE_BUCKET_PLUGIN, + compositeType: TYPE_STORAGE_BUCKET_PLUGIN_AWS_S3, + name: 'AWS', + description: 'this has an aws plugin', + bucket_name: 'bucketname', + bucket_prefix: 'bucketprefix', + worker_filter: 'workerfilter', + region: 'eu-west-1', + access_key_id: 'foobars', + secret_access_key: 'testing', + secrets_hmac: 'hmac', + disable_credential_rotation: true, + version: 1, + }, + }, + }); + const record = store.peekRecord('storage-bucket', '1'); + const snapshot = record._createSnapshot(); + const serializedRecord = serializer.serialize(snapshot); + + const expectedResult = { + name: 'AWS', + description: 'this has an aws plugin', + type: TYPE_STORAGE_BUCKET_PLUGIN, + worker_filter: 'workerfilter', + attributes: { + region: 'eu-west-1', + disable_credential_rotation: true, + }, + secrets: { + access_key_id: 'foobars', + secret_access_key: 'testing', + }, + version: 1, + }; + assert.deepEqual(serializedRecord, expectedResult); + }); + + test('it normalizes correctly', function (assert) { + assert.expect(1); + const store = this.owner.lookup('service:store'); + const serializer = store.serializerFor('storage-bucket'); + const hostSetModelClass = store.createRecord('storage-bucket').constructor; + const payload = { + id: '1', + version: 1, + name: 'AWS', + description: 'this has an aws plugin', + type: TYPE_STORAGE_BUCKET_PLUGIN, + bucket_name: 'bucketname', + bucket_prefix: 'bucketprefix', + worker_filter: 'workerfilter', + attributes: { + region: 'eu-west-1', + disable_credential_rotation: true, + }, + secrets_hmac: 'this is secret', + }; + const normalized = serializer.normalizeSingleResponse( + store, + hostSetModelClass, + payload + ); + + assert.deepEqual(normalized, { + included: [], + data: { + id: '1', + attributes: { + type: TYPE_STORAGE_BUCKET_PLUGIN, + version: 1, + authorized_actions: [], + name: 'AWS', + description: 'this has an aws plugin', + bucket_name: 'bucketname', + bucket_prefix: 'bucketprefix', + worker_filter: 'workerfilter', + secrets_hmac: 'this is secret', + region: 'eu-west-1', + disable_credential_rotation: true, + access_key_id: null, + secret_access_key: null, + }, + type: 'storage-bucket', + relationships: {}, + }, + }); + }); +}); diff --git a/addons/api/tests/unit/serializers/target-test.js b/addons/api/tests/unit/serializers/target-test.js index 447a863e7..6b1fe308d 100644 --- a/addons/api/tests/unit/serializers/target-test.js +++ b/addons/api/tests/unit/serializers/target-test.js @@ -53,6 +53,8 @@ module('Unit | Serializer | target', function (hooks) { attributes: { default_port: 1234, default_client_port: 4321, + enable_session_recording: false, + storage_bucket_id: null, }, address: '0.0.0.0', }); @@ -129,6 +131,45 @@ module('Unit | Serializer | target', function (hooks) { }); }); + test('it serializes the storage_bucket_id attribute if present', function (assert) { + assert.expect(1); + const store = this.owner.lookup('service:store'); + const serializer = store.serializerFor('target'); + const record = store.createRecord('target', { + name: 'User', + description: 'Description', + version: 1, + type: TYPE_TARGET_SSH, + enable_session_recording: true, + storage_bucket_id: 'bucketID', + default_port: 1234, + scope: { + scope_id: 'org_1', + }, + }); + const snapshot = record._createSnapshot(); + const serializedRecord = serializer.serialize(snapshot); + assert.deepEqual(serializedRecord, { + name: 'User', + description: 'Description', + version: 1, + type: TYPE_TARGET_SSH, + scope_id: 'org_1', + session_max_seconds: 28800, + session_connection_limit: null, + worker_filter: null, + egress_worker_filter: null, + ingress_worker_filter: null, + attributes: { + default_port: 1234, + default_client_port: null, + enable_session_recording: true, + storage_bucket_id: 'bucketID', + }, + address: null, + }); + }); + test('it serializes the worker_filter attribute if present', function (assert) { assert.expect(1); const store = this.owner.lookup('service:store'); @@ -160,6 +201,8 @@ module('Unit | Serializer | target', function (hooks) { attributes: { default_port: 1234, default_client_port: null, + enable_session_recording: false, + storage_bucket_id: null, }, address: null, }); @@ -196,6 +239,8 @@ module('Unit | Serializer | target', function (hooks) { attributes: { default_port: 1234, default_client_port: null, + enable_session_recording: false, + storage_bucket_id: null, }, address: null, }); @@ -232,6 +277,8 @@ module('Unit | Serializer | target', function (hooks) { attributes: { default_port: 1234, default_client_port: null, + enable_session_recording: false, + storage_bucket_id: null, }, address: null, }); @@ -443,4 +490,39 @@ module('Unit | Serializer | target', function (hooks) { }, }); }); + + test('it normalizes the storage_bucket_id attribute if present', function (assert) { + assert.expect(1); + const store = this.owner.lookup('service:store'); + const serializer = store.serializerFor('target'); + const target = store.createRecord('target').constructor; + const payload = { + id: '1', + name: 'Target 1', + storage_bucket_id: 'bucketID', + enable_session_recording: true, + }; + const normalized = serializer.normalizeSingleResponse( + store, + target, + payload + ); + assert.deepEqual(normalized, { + included: [], + data: { + id: '1', + type: 'target', + attributes: { + authorized_actions: [], + name: 'Target 1', + host_sources: [], + brokered_credential_source_ids: [], + injected_application_credential_source_ids: [], + storage_bucket_id: 'bucketID', + enable_session_recording: true, + }, + relationships: {}, + }, + }); + }); }); diff --git a/addons/api/tests/unit/transforms/duration-test.js b/addons/api/tests/unit/transforms/duration-test.js new file mode 100644 index 000000000..35027c50e --- /dev/null +++ b/addons/api/tests/unit/transforms/duration-test.js @@ -0,0 +1,49 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Transform | duration', function (hooks) { + setupTest(hooks); + + test('it deserializes a duration string', function (assert) { + assert.expect(8); + const transform = this.owner.lookup('transform:duration'); + + const num1 = transform.deserialize('0s'); + const num2 = transform.deserialize(undefined); + const num3 = transform.deserialize('3s'); + const num4 = transform.deserialize('15.003s'); + const num5 = transform.deserialize('15.0035s'); + const num6 = transform.deserialize('15.0034s'); + const num7 = transform.deserialize('3.000000001s'); + const num8 = transform.deserialize('300000000001s'); + + assert.strictEqual(num1, 0); + assert.strictEqual(num2, 0); + assert.strictEqual(num3, 3000); + assert.strictEqual(num4, 15003); + assert.strictEqual(num5, 15004); + assert.strictEqual(num6, 15003); + assert.strictEqual(num7, 3000); + assert.strictEqual(num8, 300000000001000); + }); + + test('it serializes a duration number', function (assert) { + assert.expect(7); + const transform = this.owner.lookup('transform:duration'); + const num1 = transform.serialize(0); + const num2 = transform.serialize(undefined); + const num3 = transform.serialize(3000); + const num4 = transform.serialize(15003); + const num5 = transform.serialize(15000000000001); + const num6 = transform.serialize(0.000001); + const num7 = transform.serialize(0.0000001); + + assert.strictEqual(num1, '0s'); + assert.strictEqual(num2, '0s'); + assert.strictEqual(num3, '3s'); + assert.strictEqual(num4, '15.003s'); + assert.strictEqual(num5, '15000000000.001s'); + assert.strictEqual(num6, '0.000000001s'); + assert.strictEqual(num7, '0s'); + }); +}); diff --git a/addons/core/addon/helpers/format-bytes-size.js b/addons/core/addon/helpers/format-bytes-size.js new file mode 100644 index 000000000..ef4b46c02 --- /dev/null +++ b/addons/core/addon/helpers/format-bytes-size.js @@ -0,0 +1,12 @@ +import { helper } from '@ember/component/helper'; +import { filesize } from 'filesize'; + +/** + * Takes a int expressing file size in bytes and returns a conversion + * into a human-readable string. + * "e.g. {{format-bytes-size 164730000}} // 164,73 MB" + */ +export default helper(function formatBytesSize([sizeInBytes]) { + const numberOfBytes = Number(sizeInBytes); + return filesize(numberOfBytes); +}); diff --git a/addons/core/addon/helpers/format-date-iso-human.js b/addons/core/addon/helpers/format-date-iso-human.js index 62100cc33..cb4af11fe 100644 --- a/addons/core/addon/helpers/format-date-iso-human.js +++ b/addons/core/addon/helpers/format-date-iso-human.js @@ -11,7 +11,7 @@ import { helper } from '@ember/component/helper'; */ export default helper(function formatDateIsoHuman(params /*, hash*/) { return params[0] - .toISOString() + ?.toISOString() .replace('T', ' ') .replace(/\.\d*Z/, ''); }); diff --git a/addons/core/addon/helpers/format-date-iso.js b/addons/core/addon/helpers/format-date-iso.js index c142a1012..1bbd56daf 100644 --- a/addons/core/addon/helpers/format-date-iso.js +++ b/addons/core/addon/helpers/format-date-iso.js @@ -10,5 +10,5 @@ import { helper } from '@ember/component/helper'; * e.g. "2020-01-01T00:00:00.999Z". */ export default helper(function formatDateIso(params /*, hash*/) { - return params[0].toISOString(); + return params[0]?.toISOString(); }); diff --git a/addons/core/addon/helpers/format-time-duration.js b/addons/core/addon/helpers/format-time-duration.js new file mode 100644 index 000000000..5477b5b40 --- /dev/null +++ b/addons/core/addon/helpers/format-time-duration.js @@ -0,0 +1,25 @@ +import Helper from '@ember/component/helper'; +import { inject as service } from '@ember/service'; +import DurationUnitFormat from 'intl-unofficial-duration-unit-format'; + +export default class extends Helper { + // =services + @service intl; + + // =methods + /** + * + * @param {number} durationInMs Duration in milliseconds + * @returns localized human-readable duration string. For example, in English "1 hr 23 min 20 sec" + */ + compute([durationInMs]) { + const durationInSeconds = durationInMs / 1000; + const durationFormat = new DurationUnitFormat(this.intl.primaryLocale, { + style: 'narrow', + format: '{days} {hours} {minutes} {seconds}', + round: true, + }); + + return durationFormat.format(durationInSeconds); + } +} diff --git a/addons/core/addon/helpers/indexed-display-name.js b/addons/core/addon/helpers/indexed-display-name.js new file mode 100644 index 000000000..71eafbcfc --- /dev/null +++ b/addons/core/addon/helpers/indexed-display-name.js @@ -0,0 +1,27 @@ +import Helper from '@ember/component/helper'; +import { inject as service } from '@ember/service'; + +export function indexedDisplayName(intl, translation, array, item) { + let index = array.indexOf(item) + 1; + if (index === 0) index = null; + + return intl.t(translation, { index }); +} + +export default class IndexedDisplayName extends Helper { + // =services + @service intl; + + // =methods + /** + * + * @param {string} translation + * @param {Array} array parent array of item + * @param {object} item object that you want to find the index of + * indexOf will not do a deep equality check with item, just if the object exist in the array + * @returns localized name for resource with indexed position. For example, "Channel 1" for first item in a list. + */ + compute([translation, array, item]) { + return indexedDisplayName(this.intl, translation, array, item); + } +} diff --git a/addons/core/app/helpers/format-bytes-size.js b/addons/core/app/helpers/format-bytes-size.js new file mode 100644 index 000000000..3409ec92e --- /dev/null +++ b/addons/core/app/helpers/format-bytes-size.js @@ -0,0 +1 @@ +export { default } from 'core/helpers/format-bytes-size'; diff --git a/addons/core/app/helpers/format-time-duration.js b/addons/core/app/helpers/format-time-duration.js new file mode 100644 index 000000000..fa028c296 --- /dev/null +++ b/addons/core/app/helpers/format-time-duration.js @@ -0,0 +1 @@ +export { default } from 'core/helpers/format-time-duration'; diff --git a/addons/core/app/helpers/indexed-display-name.js b/addons/core/app/helpers/indexed-display-name.js new file mode 100644 index 000000000..f64b9508e --- /dev/null +++ b/addons/core/app/helpers/indexed-display-name.js @@ -0,0 +1 @@ +export { default } from 'core/helpers/indexed-display-name'; diff --git a/addons/core/package.json b/addons/core/package.json index eb8220173..c7810f3da 100644 --- a/addons/core/package.json +++ b/addons/core/package.json @@ -40,7 +40,9 @@ "ember-feature-flags": "^6.0.0", "ember-intl": "^6.0.0-beta.4", "ember-loading": "^2.0.0", - "ember-truth-helpers": "^3.0.0" + "ember-truth-helpers": "^3.0.0", + "filesize": "^10.0.7", + "intl-unofficial-duration-unit-format": "^3.1.0" }, "devDependencies": { "@babel/eslint-parser": "^7.18.2", diff --git a/addons/core/tests/integration/helpers/format-bytes-size-test.js b/addons/core/tests/integration/helpers/format-bytes-size-test.js new file mode 100644 index 000000000..4e5415f7b --- /dev/null +++ b/addons/core/tests/integration/helpers/format-bytes-size-test.js @@ -0,0 +1,102 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Helper | format-bytes-size', function (hooks) { + setupRenderingTest(hooks); + + test('it renders file size in human-readable string', async function (assert) { + assert.expect(18); + // Size to B + const expectedSizeB = '1 B'; + this.set('inputNumberSizeB', 1); + this.set('inputStringSizeB', '1'); + + await render(hbs`{{format-bytes-size this.inputNumberSizeB}}`); + assert.dom(this.element).hasText(expectedSizeB); + await render(hbs`{{format-bytes-size this.inputStringSizeB}}`); + assert.dom(this.element).hasText(expectedSizeB); + + // Size to kB + const expectedSizeKb = '108.93 kB'; + this.set('inputNumberSizeKb', 108930); + this.set('inputStringSizeKb', '108930'); + + await render(hbs`{{format-bytes-size this.inputNumberSizeKb}}`); + assert.dom(this.element).hasText(expectedSizeKb); + await render(hbs`{{format-bytes-size this.inputStringSizeKb}}`); + assert.dom(this.element).hasText(expectedSizeKb); + + // Size to MB + const expectedSizeMb = '164.73 MB'; + this.set('inputNumberSizeMb', 164730000); + this.set('inputStringSizeMb', '164730000'); + + await render(hbs`{{format-bytes-size this.inputNumberSizeMb}}`); + assert.dom(this.element).hasText(expectedSizeMb); + await render(hbs`{{format-bytes-size this.inputStringSizeMb}}`); + assert.dom(this.element).hasText(expectedSizeMb); + + // Size to GB + const expectedSizeGb = '2.15 GB'; + this.set('inputNumberSizeGb', 2147483647); + this.set('inputStringSizeGb', '2147483647'); + + await render(hbs`{{format-bytes-size this.inputNumberSizeGb}}`); + assert.dom(this.element).hasText(expectedSizeGb); + await render(hbs`{{format-bytes-size this.inputStringSizeGb}}`); + assert.dom(this.element).hasText(expectedSizeGb); + + // Size to TB + const expectedSizeTb = '12.37 TB'; + this.set('inputNumberSizeTb', 12370000000000); + this.set('inputStringSizeTb', '12370000000000'); + + await render(hbs`{{format-bytes-size this.inputNumberSizeTb}}`); + assert.dom(this.element).hasText(expectedSizeTb); + await render(hbs`{{format-bytes-size this.inputStringSizeTb}}`); + assert.dom(this.element).hasText(expectedSizeTb); + + // Size to PB + const expectedSizePb = '9.01 PB'; + this.set('inputNumberSizePb', 9007199254740991); + this.set('inputStringSizePb', '9007199254740991'); + + await render(hbs`{{format-bytes-size this.inputNumberSizePb}}`); + assert.dom(this.element).hasText(expectedSizePb); + await render(hbs`{{format-bytes-size this.inputStringSizePb}}`); + assert.dom(this.element).hasText(expectedSizePb); + + /* eslint-disable no-loss-of-precision */ + // Size to EB + const expectedSizeEb = '1.24 EB'; + this.set('inputNumberSizeEb', 1237000000000000000); + this.set('inputStringSizeEb', '1237000000000000000'); + + await render(hbs`{{format-bytes-size this.inputNumberSizeEb}}`); + assert.dom(this.element).hasText(expectedSizeEb); + await render(hbs`{{format-bytes-size this.inputStringSizeEb}}`); + assert.dom(this.element).hasText(expectedSizeEb); + + // Size to ZB + const expectedSizeZb = '900.72 ZB'; + this.set('inputNumberSizeZb', 900719985474899195498367); + this.set('inputStringSizeZb', '900719985474899195498367'); + + await render(hbs`{{format-bytes-size this.inputNumberSizeZb}}`); + assert.dom(this.element).hasText(expectedSizeZb); + await render(hbs`{{format-bytes-size this.inputStringSizeZb}}`); + assert.dom(this.element).hasText(expectedSizeZb); + + // Size to YB + const expectedSizeYb = '90.07 YB'; + this.set('inputNumberSizeYb', 90071992547409919549836723); + this.set('inputStringSizeYb', '90071992547409919549836723'); + + await render(hbs`{{format-bytes-size this.inputNumberSizeYb}}`); + assert.dom(this.element).hasText(expectedSizeYb); + await render(hbs`{{format-bytes-size this.inputStringSizeYb}}`); + assert.dom(this.element).hasText(expectedSizeYb); + }); +}); diff --git a/addons/core/tests/integration/helpers/format-date-iso-human-test.js b/addons/core/tests/integration/helpers/format-date-iso-human-test.js index 11b97efbe..4037cf143 100644 --- a/addons/core/tests/integration/helpers/format-date-iso-human-test.js +++ b/addons/core/tests/integration/helpers/format-date-iso-human-test.js @@ -18,4 +18,12 @@ module('Integration | Helper | format-date-iso-human', function (hooks) { assert.strictEqual(this.element.textContent.trim(), '2020-01-01 00:00:00'); }); + + test('it renders when null', async function (assert) { + this.set('inputValue', null); + + await render(hbs`{{format-date-iso-human this.inputValue }}`); + + assert.strictEqual(this.element.textContent.trim(), ''); + }); }); diff --git a/addons/core/tests/integration/helpers/format-date-iso-test.js b/addons/core/tests/integration/helpers/format-date-iso-test.js index abd5a1333..9e78495b2 100644 --- a/addons/core/tests/integration/helpers/format-date-iso-test.js +++ b/addons/core/tests/integration/helpers/format-date-iso-test.js @@ -21,4 +21,12 @@ module('Integration | Helper | format-date-iso', function (hooks) { '2020-01-01T00:00:00.999Z' ); }); + + test('it renders when null', async function (assert) { + this.set('inputValue', null); + + await render(hbs`{{format-date-iso this.inputValue}}`); + + assert.strictEqual(this.element.textContent.trim(), ''); + }); }); diff --git a/addons/core/tests/integration/helpers/format-time-duration-test.js b/addons/core/tests/integration/helpers/format-time-duration-test.js new file mode 100644 index 000000000..7efa7adb9 --- /dev/null +++ b/addons/core/tests/integration/helpers/format-time-duration-test.js @@ -0,0 +1,30 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { setupIntl } from 'ember-intl/test-support'; + +module('Integration | Helper | format-time-duration', function (hooks) { + setupRenderingTest(hooks); + setupIntl(hooks); + + test('It renders in human-readable duration strings given different durations in milliseconds', async function (assert) { + // Set mock input (in milliseconds) durations + this.set('inputDurationSeconds', 3000); + this.set('inputDurationMinutes', 2700000); + this.set('inputDurationHours', 5000000); + this.set('inputDurationDays', 177800000); + + await render(hbs`{{format-time-duration this.inputDurationSeconds}}`); + assert.dom(this.element).hasText('3s'); + + await render(hbs`{{format-time-duration this.inputDurationMinutes}}`); + assert.dom(this.element).hasText('45m'); + + await render(hbs`{{format-time-duration this.inputDurationHours}}`); + assert.dom(this.element).hasText('1h 23m 20s'); + + await render(hbs`{{format-time-duration this.inputDurationDays}}`); + assert.dom(this.element).hasText('2d 1h 23m 20s'); + }); +}); diff --git a/addons/core/tests/integration/helpers/indexed-display-name-test.js b/addons/core/tests/integration/helpers/indexed-display-name-test.js new file mode 100644 index 000000000..9e8c12950 --- /dev/null +++ b/addons/core/tests/integration/helpers/indexed-display-name-test.js @@ -0,0 +1,50 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { setupIntl } from 'ember-intl/test-support'; + +module('Integration | Helper | indexed-display-name', function (hooks) { + setupRenderingTest(hooks); + setupIntl(hooks); + + test('it renders the resource name with its position in parent array', async function (assert) { + this.set('array', ['Boundary', 'is', 'cool!']); + + await render(hbs`{{indexed-display-name + 'resources.session-recording.connection.title_index' + this.array + 'Boundary' + }}`); + + assert.dom(this.element).hasText('Connection 1'); + + await render(hbs`{{indexed-display-name + 'resources.session-recording.connection.title_index' + this.array + 'is' + }}`); + + assert.dom(this.element).hasText('Connection 2'); + + await render(hbs`{{indexed-display-name + 'resources.session-recording.connection.title_index' + this.array + 'cool!' + }}`); + + assert.dom(this.element).hasText('Connection 3'); + }); + + test('it does not render the position if the index cannot be found', async function (assert) { + this.set('array', ['Boundary', 'is', 'cool!']); + + await render(hbs`{{indexed-display-name + 'resources.session-recording.connection.title_index' + this.array + 'Watchtower' + }}`); + + assert.dom(this.element).hasText('Connection'); + }); +}); diff --git a/addons/core/translations/actions/en-us.yaml b/addons/core/translations/actions/en-us.yaml index eb8fe0e4c..8b0a726fc 100644 --- a/addons/core/translations/actions/en-us.yaml +++ b/addons/core/translations/actions/en-us.yaml @@ -3,6 +3,7 @@ add: Add cancel: Cancel +edit: Edit delete: Delete remove: Remove discard: Discard @@ -18,7 +19,9 @@ deny: Deny help: Need help? next: Next back: Back +view: View retry: Retry +play: Play get-topic-help: Get help for this topic copy-to-clipboard: Copy to Clipboard copy-error-detail-to-clipboard: Copy error detail to clipboard diff --git a/addons/core/translations/en-us.yaml b/addons/core/translations/en-us.yaml index a482f52fc..97bfa823d 100644 --- a/addons/core/translations/en-us.yaml +++ b/addons/core/translations/en-us.yaml @@ -43,6 +43,7 @@ descriptions: cluster-url-initialization: To get started, please enter your cluster URL create-but-not-list: You do not have permission to list {resource}, but you can create new instances. neither-list-nor-create: You do not have permission to list or create {resource}. + cannot-download: You do not have permission to download and playback {resource}. no-results: We found no matching records. choose-a-provider: There are several built-in plugins for the most common providers. You can choose from one of the included plugins or use a custom plugin. provider: This is the provider for this host catalog. The format of the host set filters are specific to each provider. diff --git a/addons/core/translations/form/en-us.yaml b/addons/core/translations/form/en-us.yaml index 11af2b1fd..7c2236ef1 100644 --- a/addons/core/translations/form/en-us.yaml +++ b/addons/core/translations/form/en-us.yaml @@ -170,3 +170,5 @@ tags: secret-editor: details: This secret is saved but won't be displayed edit: Click to replace this secret +created: + label: Created diff --git a/addons/core/translations/resources/en-us.yaml b/addons/core/translations/resources/en-us.yaml index 281fd20ca..ae4e0d79a 100644 --- a/addons/core/translations/resources/en-us.yaml +++ b/addons/core/translations/resources/en-us.yaml @@ -213,6 +213,115 @@ org: new: New Org actions: delete: Delete Org +session-recording: + title: Session Recording + title_plural: Session Recordings + description: Protocol-aware recordings capture data and actions during user sessions. Operators may review recordings for auditing and analysis. + details: Session details + related: Related + up: '{bytes} up' + down: '{bytes} down' + duration: '{time} duration' + connection: + title: Connection + title_index: Connection {index} + title_plural: Connections + form: + channel: + label: Channel + summary: + label: Summary + channels-by-connection: + title: Channels by connection + messages: + no-channels: There are no channels for this connection + started-no-connections: + title: Session Recording In Progress + description: The Session has started but no connections have been made. + unknown-no-connections: + title: Session Recording Failure + description: This Session cannot be played back because no connections were established, and the Session has failed. + channel: + title: Channel + title_index: Channel {index} + title_plural: Channels + details: Channel details + messages: + none: + title: Channel Playback + missing: + title: We can't play back this channel because the file is missing + description: This likely means the connected storage bucket is misconfigured or the file was deleted. + link: Retrieving lost session recordings + not-supported: + title: Playback is not supported for this channel + description: The contents of this channel cannot be replayed. + player: + back-navigation: Back to channels + loading: Loading recording… + session: + title: Session Playback + form: + time: + label: Time + user: + label: User + target: + label: Target + duration: + label: Duration + status: + label: Status + messages: + none: + title: Welcome to session recording + description: Review, playback, and download recorded sessions. To get started, configure recording on a target and start a session. + no-storage-bucket-description: To start using session recordings, create a storage bucket and configure recording on a target. +storage-bucket: + title: Storage Bucket + title_plural: Storage Buckets + description: External services store data from session recordings. + messages: + none: + title: No storage buckets yet + description: To start using session recording, you need to have an Amazon S3 bucket. + link: Add a new storage bucket + titles: + new: New Storage Bucket + amazon_s3: Amazon S3 + actions: + delete: Delete Storage Bucket + types: + aws_s3: AWS S3 + form: + scope: + label: Scope + help: This storage bucket can be associated with targets in this scope. + provider: + label: Provider + help: Boundary currently only supports Amazon S3 storage. + bucket_name: + label: Bucket name + help: The name of this bucket in AWS + bucket_prefix: + label: Bucket prefix + help: The base path where session recordings will be stored. + region: + label: Region + help: The AWS region to use + access_key_id: + label: Access key ID + help: The Access key ID generated by AWS for the IAM User to use with this storage bucket. We will not show this data after it is saved, but you can replace it. + secret_access_key: + label: Secret access key + help: The secret access key generated by AWS for the IAM user to use with this storage bucket. We will not show this data after it is saved, but you can replace it. + worker_filter: + label: Worker filter + help: Filter for Boundary workers that have network access to your storage. Boolean expression. + questions: + delete-storage-bucket: + title: Delete associated recordings? + message: This storage bucket may have session recordings associated with it. Deleting this storage bucket will delete these recordings when you save your changes. project: title: Project title_plural: Projects @@ -310,6 +419,8 @@ target: add-brokered-credential-sources: Add Brokered Credentials add-injected-application-credential-sources: Add Injected Application Credentials remove-address: Remove address and save + enable-session-recording: Enable Session Recording + enable-recording: Enable recording types: tcp: Generic TCP ssh: SSH @@ -332,6 +443,10 @@ target: default_client_port: label: Default Client Port help: The local proxy port on which to listen by default when a session is started on a client. + sidebar: + label: Session Recording + help: Record SSH sessions on this target and save them to an Amazon S3 bucket. + link: Session Recording settings questions: delete-host-sources: title: Remove associated host sources? @@ -353,6 +468,17 @@ target: delete-address: title: Remove target address? message: This target has an assigned address. Adding a host source will remove the assigned address from the target when you save your changes. + enable-session-recording: + title: Enable Session Recording + messages: + add: + title: Enable Session Recording for Target + form: + filter: + label: Record sessions for this target + select: + label: AWS storage buckets + description: Sessions will be recorded and stored in this location. Must use Amazon S3. brokered-credential-source: title: Brokered Credential title_plural: Brokered Credentials @@ -604,6 +730,14 @@ worker: worker_tags: label: Worker Tags help: Boundary can use tags to define key-value pairs which targets can use to determine where they should route connections. + local_session_recording_storage: + label: Local Session Recording Storage + help: To record sessions using this worker, specify a local storage path and ensure that it is provisioned with sufficient capacity. In progress recordings are stored locally until they complete, and then moved to the remote storage bucket associated with their target. + enable_recording_storage_path: + label: Enable Recording Storage + recording_storage_path: + label: Recording Storage Path + help: Local path on the worker node for temporary file storage. steps: 1: title: 1. Configure your new worker diff --git a/addons/core/translations/states/en-us.yaml b/addons/core/translations/states/en-us.yaml index 07721a51a..94875ceb9 100644 --- a/addons/core/translations/states/en-us.yaml +++ b/addons/core/translations/states/en-us.yaml @@ -11,3 +11,6 @@ primary: Primary inactive: Inactive active-private: Private active-public: Public +recording: Recording +completed: Completed +failed: Failed diff --git a/addons/rose/addon/styles/hds/themes/dark-mode/index.scss b/addons/rose/addon/styles/hds/themes/dark-mode/index.scss index 14ab0905d..38ab4cf09 100644 --- a/addons/rose/addon/styles/hds/themes/dark-mode/index.scss +++ b/addons/rose/addon/styles/hds/themes/dark-mode/index.scss @@ -102,6 +102,9 @@ --token-color-palette-neutral-200 ); --token-form-indicator-optional-color: var(--token-color-palette-neutral-200); + --token-color-foreground-high-contrast: var( + --token-color-palette-neutral-700 + ); } @media (prefers-color-scheme: dark) { diff --git a/package.json b/package.json index 097a543f7..33c0d9a83 100755 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "test:addons-rose": "yarn --cwd addons/rose test", "test:ui-admin": "yarn --cwd ui/admin test", "test:ui-desktop": "yarn --cwd ui/desktop test", - "compliance:licenses": "license-checker --onlyAllow 'Apache*;Apache License, Version 2.0;Apache-2.0;Apache 2.0;Artistic-2.0;BSD;BSD-3-Clause;CC-BY-3.0;CC-BY-4.0;CC0-1.0;ISC;MIT;MPL-2.0;Public Domain;Python-2.0;Unicode-TOU;Unlicense;WTFPL' --excludePackages 'boundary-ui;admin@0.0.0;desktop@0.0.0;base64-js@0.0.2;cycle@1.0.3;cldr-core@41.0.0;make-plural@7.2.0'", + "compliance:licenses": "license-checker --onlyAllow 'Apache*;Apache License, Version 2.0;Apache-2.0;Apache 2.0;Artistic-2.0;BSD;BSD-3-Clause;CC-BY-3.0;CC-BY-4.0;CC0-1.0;ISC;MIT;MPL-2.0;Public Domain;Python-2.0;Unicode-TOU;Unlicense;WTFPL' --excludePackages 'boundary-ui;admin@0.0.0;desktop@0.0.0;base64-js@0.0.2;cycle@1.0.3;cldr-core@41.0.0;make-plural@7.2.0;@hashicorp/ember-asciinema-player@0.0.0'", "doc:toc": "doctoc README.md", "prepare": "husky install" }, diff --git a/ui/admin/app/abilities/scope.js b/ui/admin/app/abilities/scope.js new file mode 100644 index 000000000..736a72cf0 --- /dev/null +++ b/ui/admin/app/abilities/scope.js @@ -0,0 +1,48 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import OverrideModelAbility from './model'; +import { inject as service } from '@ember/service'; + +export default class OverrideScopeAbility extends OverrideModelAbility { + // =services + + @service features; + + // =attributes + + /** + * Creating a resource is allowed only if a create grant is present + * and collection-specific criteria are met. + * @type {boolean} + */ + get canCreate() { + switch (this.collection) { + case 'storage-buckets': + return ( + this.features.isEnabled('ssh-session-recording') && super.canCreate + ); + default: + return super.canCreate; + } + } + + /** + * Listing a resource is allowed only if a list grant is present + * and collection-specific criteria are met. + * @type {boolean} + */ + get canList() { + switch (this.collection) { + case 'session-recordings': + case 'storage-buckets': + return ( + this.features.isEnabled('ssh-session-recording') && super.canList + ); + default: + return super.canList; + } + } +} diff --git a/ui/admin/app/abilities/session-recording.js b/ui/admin/app/abilities/session-recording.js new file mode 100644 index 000000000..6f5164444 --- /dev/null +++ b/ui/admin/app/abilities/session-recording.js @@ -0,0 +1,22 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import SessionRecordingAbility from 'api/abilities/session-recording'; +import { inject as service } from '@ember/service'; + +export default class OverrideSessionRecordingAbility extends SessionRecordingAbility { + // =service + @service features; + + /** + * This override ensures that session recordings may be read only if the + * session-recording feature flag is enabled. + */ + get canRead() { + return this.features.isEnabled('ssh-session-recording') + ? super.canRead + : false; + } +} diff --git a/ui/admin/app/abilities/storage-bucket.js b/ui/admin/app/abilities/storage-bucket.js new file mode 100644 index 000000000..59fcce9e5 --- /dev/null +++ b/ui/admin/app/abilities/storage-bucket.js @@ -0,0 +1,44 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import StorageBucketAbility from 'api/abilities/storage-bucket'; +import { inject as service } from '@ember/service'; + +export default class OverrideStorageBucketAbility extends StorageBucketAbility { + // =service + @service features; + + /** + * This override ensures that storage buckets may be read only if the + * session-recording feature flag is enabled. + */ + get canRead() { + return this.features.isEnabled('ssh-session-recording') + ? super.canRead + : false; + } + + /** + * This override ensures that storage buckets may be updated only if the + * session-recording feature flag is enabled. + */ + get canUpdate() { + return this.features.isEnabled('ssh-session-recording') + ? super.canUpdate + : false; + } + + /** + * TODO: Ensure that storage buckets may be deleted only if the + * session-recording feature flag is enabled. + * For now a storage bucket cannot be deleted. + */ + get canDelete() { + return false; + // return this.features.isEnabled('ssh-session-recording') + // ? super.canDelete + // : false; + } +} diff --git a/ui/admin/app/components/form/field/secret-input/index.hbs b/ui/admin/app/components/form/field/secret-input/index.hbs new file mode 100644 index 000000000..b47965f28 --- /dev/null +++ b/ui/admin/app/components/form/field/secret-input/index.hbs @@ -0,0 +1,43 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} + +
+ {{#if (or this.isDisabled this.isActionable)}} + + {{/if}} + + {{yield F}} + + + {{#if this.isDisabled}} + + {{else if this.isActionable}} + + {{else}} + + {{/if}} +
\ No newline at end of file diff --git a/ui/admin/app/components/form/field/secret-input/index.js b/ui/admin/app/components/form/field/secret-input/index.js new file mode 100644 index 000000000..b3e3c8368 --- /dev/null +++ b/ui/admin/app/components/form/field/secret-input/index.js @@ -0,0 +1,57 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; + +export default class FormFieldSecretInputIndexComponent extends Component { + // =properties + /** + * Tracks when secret can be updated + * @type {boolean} + */ + @tracked isEditing = false; + + /** + * Returns true if the resource form is in read only mode + * @type {boolean} + */ + get isDisabled() { + return this.args.isDisabled; + } + + /** + * Returns true if the resource form is in edit mode and + * `edit` button is enabled for the secret field. + * This state lets the user to update the secret fields if they like + * @type {boolean} + */ + get isActionable() { + return this.args.showEditButton && !this.isEditing; + } + + // =actions + + /** + * Sets the secret field to be updated + * when `edit` button is clicked in Actionable view + */ + @action + enableEdit() { + if (!this.args.isDisabled) this.isEditing = true; + } + + /** + * Clears updated secret when cancel button is + * clicked in Actionable view. + * Sets the secret field back to disabled mode + */ + @action + cancelEdit() { + this.isEditing = false; + this.args.cancel(); + } +} diff --git a/ui/admin/app/components/form/storage-bucket/index.hbs b/ui/admin/app/components/form/storage-bucket/index.hbs new file mode 100644 index 000000000..4df4c75dd --- /dev/null +++ b/ui/admin/app/components/form/storage-bucket/index.hbs @@ -0,0 +1,321 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} + + + + {{t 'form.name.label'}} + {{t 'form.name.help'}} + {{#if @model.errors.name}} + + {{#each @model.errors.name as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + + + {{t 'form.description.label'}} + {{t 'form.description.help'}} + {{#if @model.errors.description}} + + {{#each @model.errors.description as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + + {{#if @model.isNew}} + + {{t 'resources.storage-bucket.form.scope.label'}} + + {{t 'resources.storage-bucket.form.scope.help'}} + + + {{#each @scopes as |scope|}} + + {{/each}} + + + {{else}} + + {{t 'resources.storage-bucket.form.scope.label'}} + {{t + 'resources.storage-bucket.form.scope.help' + }} + + {{/if}} + + + {{t 'titles.provider'}} + {{t + 'resources.storage-bucket.form.provider.help' + }} + + + + {{t 'resources.storage-bucket.form.bucket_name.label'}} + {{t + 'resources.storage-bucket.form.bucket_name.help' + }} + {{#if @model.errors.bucket_name}} + + {{#each @model.errors.bucket_name as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + + + {{t 'resources.storage-bucket.form.bucket_prefix.label'}} + {{t + 'resources.storage-bucket.form.bucket_prefix.help' + }} + {{#if @model.errors.bucket_prefix}} + + {{#each @model.errors.bucket_prefix as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + + + {{t 'resources.storage-bucket.form.region.label'}} + {{t + 'resources.storage-bucket.form.region.help' + }} + {{#if @model.errors.region}} + + {{#each @model.errors.region as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + + {{#if @model.isNew}} + + {{t + 'resources.storage-bucket.form.access_key_id.label' + }} + {{t + 'resources.storage-bucket.form.access_key_id.help' + }} + {{#if @model.errors.access_key_id}} + + {{#each @model.errors.access_key_id as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + + + {{t + 'resources.storage-bucket.form.secret_access_key.label' + }} + {{t + 'resources.storage-bucket.form.secret_access_key.help' + }} + {{#if @model.errors.secret_access_key}} + + {{#each @model.errors.secret_access_key as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + {{else}} + + {{t + 'resources.storage-bucket.form.access_key_id.label' + }} + {{t + 'resources.storage-bucket.form.access_key_id.help' + }} + {{#if @model.errors.access_key_id}} + + {{#each @model.errors.access_key_id as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + + + {{t + 'resources.storage-bucket.form.secret_access_key.label' + }} + {{t + 'resources.storage-bucket.form.secret_access_key.help' + }} + {{#if @model.errors.secret_access_key}} + + {{#each @model.errors.secret_access_key as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + {{/if}} + + + {{t 'resources.storage-bucket.form.worker_filter.label'}} + + {{t 'resources.storage-bucket.form.worker_filter.help'}} + {{! TODO: Link to the correct url for worker-filters }} + {{t + 'actions.learn-more' + }} + + {{#if @model.errors.worker_filter}} + + {{#each @model.errors.worker_filter as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + + + {{t 'form.disable_credential_rotation.label'}} + + + {{#if (can 'save model' @model)}} + + {{/if}} + \ No newline at end of file diff --git a/ui/admin/app/components/form/storage-bucket/index.js b/ui/admin/app/components/form/storage-bucket/index.js new file mode 100644 index 000000000..5f8555785 --- /dev/null +++ b/ui/admin/app/components/form/storage-bucket/index.js @@ -0,0 +1,36 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { set } from '@ember/object'; + +export default class FormStorageBucketComponent extends Component { + // =actions + + @action + updateScope(event) { + const selectedScopeId = event.target.value; + const selectedScopeModel = this.args.scopes.find( + (element) => element.model.id === selectedScopeId + ).model; + this.args.model.scopeModel = selectedScopeModel; + } + + @action + rollbackSecretAttrs(currentAttr) { + const changedAttrs = this.args.model.changedAttributes(); + if (currentAttr in changedAttrs) { + const [oldVal] = changedAttrs[currentAttr]; + set(this.args.model, currentAttr, oldVal); + } + } + + @action + toggleDisableCredentialRotation() { + this.args.model.disable_credential_rotation = + !this.args.model.disable_credential_rotation; + } +} diff --git a/ui/admin/app/components/form/target/details/index.hbs b/ui/admin/app/components/form/target/details/index.hbs index 685358d9f..91e247e64 100644 --- a/ui/admin/app/components/form/target/details/index.hbs +++ b/ui/admin/app/components/form/target/details/index.hbs @@ -4,6 +4,7 @@ }} +
+ + {{t + 'resources.target.enable-session-recording.form.filter.label' + }} + + {{#if @model.enable_session_recording}} + + {{t + 'resources.target.enable-session-recording.form.select.label' + }} + + {{t + 'resources.target.enable-session-recording.form.select.description' + }} + + + + {{#each @storageBucketList as |bucket|}} + + {{/each}} + + {{#if @model.errors.storage_bucket_id}} + + {{#each @model.errors.storage_bucket_id as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + {{/if}} + + + + +
+
\ No newline at end of file diff --git a/ui/admin/app/components/form/target/enable-session-recording/index.js b/ui/admin/app/components/form/target/enable-session-recording/index.js new file mode 100644 index 000000000..9e937c988 --- /dev/null +++ b/ui/admin/app/components/form/target/enable-session-recording/index.js @@ -0,0 +1,19 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; + +export default class FormEnableSessionRecordingIndexComponent extends Component { + //actions + /** + * toggle to enable session recording for the target + */ + @action + toggleSessionRecording() { + this.args.model.enable_session_recording = + !this.args.model.enable_session_recording; + } + + @action + selectStorageBucket({ target: { value: selectedStorageBucketID } }) { + this.args.model.storage_bucket_id = selectedStorageBucketID; + } +} diff --git a/ui/admin/app/components/form/worker/create-worker-led/index.hbs b/ui/admin/app/components/form/worker/create-worker-led/index.hbs index 5ed7d7fbc..9dc167d24 100644 --- a/ui/admin/app/components/form/worker/create-worker-led/index.hbs +++ b/ui/admin/app/components/form/worker/create-worker-led/index.hbs @@ -51,12 +51,12 @@ /> {{/if}} - - <:title>{{t 'resources.worker.form.worker_tags.label'}} - <:description>{{t + + {{t 'resources.worker.form.worker_tags.label'}} + {{t 'resources.worker.form.worker_tags.help' - }} - <:body> + }} + {{#each this.workerTags as |tag index|}} @@ -108,8 +108,46 @@ - - + + + + {{#if (feature-flag 'ssh-session-recording')}} + + {{t + 'resources.worker.form.local_session_recording_storage.label' + }} + + {{t 'resources.worker.form.local_session_recording_storage.help'}} + {{t + 'actions.learn-more' + }} + + + + {{t + 'resources.worker.form.enable_recording_storage_path.label' + }} + + + {{#if this.enableRecordingStoragePath}} + + + + {{/if}} + + {{/if}} diff --git a/ui/admin/app/components/form/worker/create-worker-led/index.js b/ui/admin/app/components/form/worker/create-worker-led/index.js index 355f9ab64..256880988 100644 --- a/ui/admin/app/components/form/worker/create-worker-led/index.js +++ b/ui/admin/app/components/form/worker/create-worker-led/index.js @@ -33,6 +33,8 @@ export default class FormWorkerCreateWorkerLedComponent extends Component { @tracked workerTags = A([]); @tracked newWorkerKey; @tracked newWorkerValue; + @tracked enableRecordingStoragePath = false; + @tracked recording_storage_path = ''; get clusterIDFromURL() { const hostname = this.window?.location?.hostname; @@ -55,6 +57,14 @@ export default class FormWorkerCreateWorkerLedComponent extends Component { touch ${this.configFilePath || ''}/pki-worker.hcl`; } + get recordingStoragePathText() { + const path = this.recording_storage_path || ''; + return this.features.isEnabled('ssh-session-recording') && + this.enableRecordingStoragePath + ? `\n recording_storage_path = "${path}"` + : ''; + } + /** * Returns config creation text for ``. The user will see * different outputs based on if they are using `hcp` or `oss` binaries. @@ -93,7 +103,7 @@ worker { this.features.isEnabled('byow-pki-hcp-cluster-id') ? `${tagsText}` : `${tagsText}${upstreamText}` - } + }${this.recordingStoragePathText} }`; if (this.features.isEnabled('byow-pki-hcp-cluster-id')) { @@ -187,4 +197,9 @@ unzip *.zip ;\\ removeWorkerTagByIndex(index) { this.workerTags.removeAt(index); } + + @action + toggleEnableRecordingStoragePath() { + this.enableRecordingStoragePath = !this.enableRecordingStoragePath; + } } diff --git a/ui/admin/app/components/layout/sidebar/body/index.hbs b/ui/admin/app/components/layout/sidebar/body/index.hbs new file mode 100644 index 000000000..d905ec2e1 --- /dev/null +++ b/ui/admin/app/components/layout/sidebar/body/index.hbs @@ -0,0 +1,3 @@ +
+ {{yield}} +
\ No newline at end of file diff --git a/ui/admin/app/components/layout/sidebar/index.hbs b/ui/admin/app/components/layout/sidebar/index.hbs new file mode 100644 index 000000000..8e66b15a7 --- /dev/null +++ b/ui/admin/app/components/layout/sidebar/index.hbs @@ -0,0 +1,8 @@ +
+ {{yield + (hash + Body=(component 'layout/sidebar/body') + Sidebar=(component 'layout/sidebar/sidebar') + ) + }} +
\ No newline at end of file diff --git a/ui/admin/app/components/layout/sidebar/sidebar/index.hbs b/ui/admin/app/components/layout/sidebar/sidebar/index.hbs new file mode 100644 index 000000000..4c986569d --- /dev/null +++ b/ui/admin/app/components/layout/sidebar/sidebar/index.hbs @@ -0,0 +1,3 @@ +
+ {{yield}} +
\ No newline at end of file diff --git a/ui/admin/app/components/link-list-panel/index.hbs b/ui/admin/app/components/link-list-panel/index.hbs new file mode 100644 index 000000000..4ac7f4064 --- /dev/null +++ b/ui/admin/app/components/link-list-panel/index.hbs @@ -0,0 +1,16 @@ + +
    + {{yield + (hash + Item=(component + 'link-list-panel/item' + icon=@icon + color=@color + text=@text + route=@route + model=@model + ) + ) + }} +
+
\ No newline at end of file diff --git a/ui/admin/app/components/link-list-panel/item/index.hbs b/ui/admin/app/components/link-list-panel/item/index.hbs new file mode 100644 index 000000000..38456f6d6 --- /dev/null +++ b/ui/admin/app/components/link-list-panel/item/index.hbs @@ -0,0 +1,46 @@ + \ No newline at end of file diff --git a/ui/admin/app/components/metadata-list/index.hbs b/ui/admin/app/components/metadata-list/index.hbs new file mode 100644 index 000000000..1d4834598 --- /dev/null +++ b/ui/admin/app/components/metadata-list/index.hbs @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/ui/admin/app/components/metadata-list/item/index.hbs b/ui/admin/app/components/metadata-list/item/index.hbs new file mode 100644 index 000000000..a3505d06f --- /dev/null +++ b/ui/admin/app/components/metadata-list/item/index.hbs @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/ui/admin/app/components/session-recording/player/index.hbs b/ui/admin/app/components/session-recording/player/index.hbs new file mode 100644 index 000000000..80a9f85f8 --- /dev/null +++ b/ui/admin/app/components/session-recording/player/index.hbs @@ -0,0 +1,18 @@ +
+
+ +
+ {{#if (has-block)}} + {{yield}} + {{else}} +
+ +
+ {{/if}} +
\ No newline at end of file diff --git a/ui/admin/app/components/session-recording/status/index.hbs b/ui/admin/app/components/session-recording/status/index.hbs new file mode 100644 index 000000000..9ad3f53cb --- /dev/null +++ b/ui/admin/app/components/session-recording/status/index.hbs @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/ui/admin/app/components/session-recording/status/index.js b/ui/admin/app/components/session-recording/status/index.js new file mode 100644 index 000000000..47a7a153e --- /dev/null +++ b/ui/admin/app/components/session-recording/status/index.js @@ -0,0 +1,37 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { + STATE_SESSION_RECORDING_STARTED, + STATE_SESSION_RECORDING_AVAILABLE, + STATE_SESSION_RECORDING_UNKNOWN, +} from 'api/models/session-recording'; + +export default class SessionRecordingStatusComponent extends Component { + // =services + @service intl; + + // =attributes + get status() { + switch (this.args.status) { + case STATE_SESSION_RECORDING_STARTED: // In progress + return { + icon: 'circle-dot', + color: 'neutral', + text: this.intl.t('states.recording'), + }; + case STATE_SESSION_RECORDING_AVAILABLE: // Completed + return { + icon: 'check-circle', + color: 'success', + text: this.intl.t('states.completed'), + }; + case STATE_SESSION_RECORDING_UNKNOWN: // Error + default: + return { + icon: 'alert-triangle', + color: 'critical', + text: this.intl.t('states.failed'), + }; + } + } +} diff --git a/ui/admin/app/controllers/scopes/scope/session-recordings.js b/ui/admin/app/controllers/scopes/scope/session-recordings.js new file mode 100644 index 000000000..d9ffc7fb0 --- /dev/null +++ b/ui/admin/app/controllers/scopes/scope/session-recordings.js @@ -0,0 +1,16 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; + +export default class ScopesScopeSessionRecordingsController extends Controller { + // =services + + @service intl; + + //= attributes + /** + * Translated breadcrumb + */ + get breadCrumb() { + return this.intl.t('resources.session-recording.title_plural'); + } +} diff --git a/ui/admin/app/controllers/scopes/scope/session-recordings/index.js b/ui/admin/app/controllers/scopes/scope/session-recordings/index.js new file mode 100644 index 000000000..6d9f05688 --- /dev/null +++ b/ui/admin/app/controllers/scopes/scope/session-recordings/index.js @@ -0,0 +1,19 @@ +import Controller from '@ember/controller'; + +export default class ScopesScopeSessionRecordingsIndexController extends Controller { + /** + * Returns true if any session recordings exist + * @type {boolean} + */ + get hasSessionRecordings() { + return this.model?.sessionRecordings?.length; + } + + /** + * Returns true if any storage buckets exist + * @type {boolean} + */ + get hasSessionRecordingsConfigured() { + return this.model?.storageBuckets?.length; + } +} diff --git a/ui/admin/app/controllers/scopes/scope/session-recordings/session-recording.js b/ui/admin/app/controllers/scopes/scope/session-recordings/session-recording.js new file mode 100644 index 000000000..cf762b744 --- /dev/null +++ b/ui/admin/app/controllers/scopes/scope/session-recordings/session-recording.js @@ -0,0 +1,15 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; + +export default class ScopesScopeSessionRecordingsSessionRecordingController extends Controller { + // =services + + @service intl; + + /** + * Session recording breadcrumb + */ + get breadCrumb() { + return this.model.displayName; + } +} diff --git a/ui/admin/app/controllers/scopes/scope/session-recordings/session-recording/channels-by-connection.js b/ui/admin/app/controllers/scopes/scope/session-recordings/session-recording/channels-by-connection.js new file mode 100644 index 000000000..a16cea405 --- /dev/null +++ b/ui/admin/app/controllers/scopes/scope/session-recordings/session-recording/channels-by-connection.js @@ -0,0 +1,18 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; + +export default class ScopesScopeSessionRecordingsSessionRecordingChannelsByConnectionController extends Controller { + // =services + + @service intl; + + // =attributes + /** + * Translated breadcrumb + */ + get breadCrumb() { + return this.intl.t( + 'resources.session-recording.channels-by-connection.title' + ); + } +} diff --git a/ui/admin/app/controllers/scopes/scope/session-recordings/session-recording/channels-by-connection/channel.js b/ui/admin/app/controllers/scopes/scope/session-recordings/session-recording/channels-by-connection/channel.js new file mode 100644 index 000000000..755d26751 --- /dev/null +++ b/ui/admin/app/controllers/scopes/scope/session-recordings/session-recording/channels-by-connection/channel.js @@ -0,0 +1,21 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; +import { indexedDisplayName } from 'core/helpers/indexed-display-name'; + +export default class ScopesScopeSessionRecordingsSessionRecordingChannelsByConnectionChannelController extends Controller { + // =services + + @service intl; + + /** + * Session recording channel breadcrumb + */ + get breadCrumb() { + return indexedDisplayName( + this.intl, + 'resources.session-recording.channel.title_index', + this.model.channelRecording.connection_recording.channel_recordings, + this.model.channelRecording + ); + } +} diff --git a/ui/admin/app/controllers/scopes/scope/session-recordings/session-recording/channels-by-connection/index.js b/ui/admin/app/controllers/scopes/scope/session-recordings/session-recording/channels-by-connection/index.js new file mode 100644 index 000000000..8a779769b --- /dev/null +++ b/ui/admin/app/controllers/scopes/scope/session-recordings/session-recording/channels-by-connection/index.js @@ -0,0 +1,31 @@ +import Controller from '@ember/controller'; +import { + STATE_SESSION_RECORDING_STARTED, + STATE_SESSION_RECORDING_UNKNOWN, +} from 'api/models/session-recording'; + +export default class ScopesScopeSessionRecordingsSessionRecordingChannelsByConnectionIndexController extends Controller { + /** + * Returns true if the session recording has started + * but has not started any connections + * @type {boolean} + */ + get isSessionInprogressWithNoConnections() { + return ( + this.model?.sessionRecording?.connection_recordings?.length === 0 && + this.model?.sessionRecording?.state === STATE_SESSION_RECORDING_STARTED + ); + } + + /** + * Returns true if the session recording state is unknown + * and has no connections + * @type {boolean} + */ + get isSessionUnknownWithNoConnections() { + return ( + this.model?.sessionRecording?.connection_recordings?.length === 0 && + this.model?.sessionRecording?.state === STATE_SESSION_RECORDING_UNKNOWN + ); + } +} diff --git a/ui/admin/app/controllers/scopes/scope/storage-buckets.js b/ui/admin/app/controllers/scopes/scope/storage-buckets.js new file mode 100644 index 000000000..249913c40 --- /dev/null +++ b/ui/admin/app/controllers/scopes/scope/storage-buckets.js @@ -0,0 +1,22 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; + +export default class ScopesScopeStorageBucketsController extends Controller { + // =services + + @service intl; + + // = attributes + /** + * Translated breadcrumb + * @type {string} + */ + get breadCrumb() { + return this.intl.t('resources.storage-bucket.title_plural'); + } +} diff --git a/ui/admin/app/controllers/scopes/scope/storage-buckets/new.js b/ui/admin/app/controllers/scopes/scope/storage-buckets/new.js new file mode 100644 index 000000000..40f8fcfdc --- /dev/null +++ b/ui/admin/app/controllers/scopes/scope/storage-buckets/new.js @@ -0,0 +1,21 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; + +export default class ScopesScopeStorageBucketsNewController extends Controller { + // =services + + @service intl; + + /** + * Breadcrumb for new route + * @type {string} + */ + get breadCrumb() { + return this.intl.t('resources.storage-bucket.titles.new'); + } +} diff --git a/ui/admin/app/controllers/scopes/scope/storage-buckets/storage-bucket.js b/ui/admin/app/controllers/scopes/scope/storage-buckets/storage-bucket.js new file mode 100644 index 000000000..546385d94 --- /dev/null +++ b/ui/admin/app/controllers/scopes/scope/storage-buckets/storage-bucket.js @@ -0,0 +1,13 @@ +import Controller from '@ember/controller'; + +export default class ScopesScopeStorageBucketsStorageBucketController extends Controller { + // =attributes + + /** + * A storage-bucket breadcrumb + * @type {string} + */ + get breadCrumb() { + return this.model.displayName; + } +} diff --git a/ui/admin/app/controllers/scopes/scope/targets/target/enable-session-recording.js b/ui/admin/app/controllers/scopes/scope/targets/target/enable-session-recording.js new file mode 100644 index 000000000..1af8f7f7d --- /dev/null +++ b/ui/admin/app/controllers/scopes/scope/targets/target/enable-session-recording.js @@ -0,0 +1,18 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; + +export default class ScopesScopeTargetsTargetEnableSessionRecordingController extends Controller { + // =services + + @service intl; + + // =attributes + + /** + * Translated enable session recording breadcrumb + * @type {string} + */ + get breadCrumb() { + return this.intl.t('resources.target.enable-session-recording.title'); + } +} diff --git a/ui/admin/app/helpers/can.js b/ui/admin/app/helpers/can.js new file mode 100644 index 000000000..b4d745383 --- /dev/null +++ b/ui/admin/app/helpers/can.js @@ -0,0 +1,48 @@ +import CanHelper from 'ember-can/helpers/can'; +import { inject as service } from '@ember/service'; +import config from 'admin/config/environment'; + +const isDevelopment = config.environment === 'development'; + +/** + * Normally, abilities that access tracked values are not recomputed on change. + * This helper overrides the Ember Can helper to trigger recompute whenever + * a feature flag changes. This functionality is available only in the + * development environment, since feature flags may be toggled in the UI, and + * also for performance reasons as this effectively recomputes _all_ `can` + * helpers on any feature flag change. + */ +class DevelopmentCanHelper extends CanHelper { + // =services + + @service features; + + // =methods + + constructor() { + super(...arguments); + this.observeAllFlags(); + } + + willDestroy() { + this.removeAllFlags(); + super.willDestroy(); + } + + observeAllFlags() { + this.features.flags.forEach((flag) => + /* eslint-disable-next-line ember/no-observers */ + this.features.addObserver(flag, this, 'recompute') + ); + } + + removeAllFlags() { + this.features.flags.forEach((flag) => + /* eslint-disable-next-line ember/no-observers */ + this.features.removeObserver(flag, this, 'recompute') + ); + } +} + +// In development, export the development helper, otherwise the original. +export default isDevelopment ? DevelopmentCanHelper : CanHelper; diff --git a/ui/admin/app/router.js b/ui/admin/app/router.js index 1c7284ff2..8b36ebc98 100644 --- a/ui/admin/app/router.js +++ b/ui/admin/app/router.js @@ -85,6 +85,7 @@ Router.map(function () { this.route('injected-application-credential-sources'); this.route('add-brokered-credential-sources'); this.route('add-injected-application-credential-sources'); + this.route('enable-session-recording'); }); this.route('new'); }); @@ -137,6 +138,25 @@ Router.map(function () { this.route('new'); this.route('worker', { path: ':worker_id' }, function () {}); }); + this.route('session-recordings', function () { + this.route( + 'session-recording', + { path: ':session_recording_id' }, + function () { + this.route('channels-by-connection', function () { + this.route('channel', { path: ':channel_id' }, function () {}); + }); + } + ); + }); + this.route('storage-buckets', function () { + this.route('new'); + this.route( + 'storage-bucket', + { path: ':storage_bucket_id' }, + function () {} + ); + }); }); }); diff --git a/ui/admin/app/routes/application.js b/ui/admin/app/routes/application.js index cbdbc109e..137527b1a 100644 --- a/ui/admin/app/routes/application.js +++ b/ui/admin/app/routes/application.js @@ -7,7 +7,6 @@ import Route from '@ember/routing/route'; import { getOwner } from '@ember/application'; import { inject as service } from '@ember/service'; import { action } from '@ember/object'; -import { loading } from 'ember-loading'; import { A } from '@ember/array'; /** @@ -75,17 +74,6 @@ export default class ApplicationRoute extends Route { this.session.invalidate(); } - /** - * Hooks into ember-loading to kick off loading indicator in the - * application template. - * @return {boolean} always returns true - */ - @action - @loading - loading() { - return true; - } - /** * Invalidates the session if a 401 error occurs and returns false to * prevent further error handling. diff --git a/ui/admin/app/routes/scopes/scope/session-recordings.js b/ui/admin/app/routes/scopes/scope/session-recordings.js new file mode 100644 index 000000000..9b2918599 --- /dev/null +++ b/ui/admin/app/routes/scopes/scope/session-recordings.js @@ -0,0 +1,58 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +export default class ScopesScopeSessionRecordingsRoute extends Route { + // =services + + @service store; + @service can; + @service session; + @service router; + + // =methods + + /** + * If arriving here unauthenticated, redirect to index for further processing. + */ + beforeModel() { + if (!this.session.isAuthenticated) this.router.transitionTo('index'); + } + + /** + * Load all session recordings. + * @return {{sessionRecordings: Array, storageBuckets: Array}} + */ + async model() { + let storageBuckets; + + const scope = this.modelFor('scopes.scope'); + const { id: scope_id } = scope; + + if ( + this.can.can('list scope', scope, { + collection: 'session-recordings', + }) + ) { + const sessionRecordings = await this.store.query('session-recording', { + scope_id, + recursive: true, + }); + + // Storage buckets could fail for a number of reasons, including that + // the user isn't authorized to access them. + try { + storageBuckets = await this.store.query('storage-bucket', { + scope_id, + recursive: true, + }); + } catch (e) { + // no op + } + + return { + sessionRecordings, + storageBuckets, + }; + } + } +} diff --git a/ui/admin/app/routes/scopes/scope/session-recordings/index.js b/ui/admin/app/routes/scopes/scope/session-recordings/index.js new file mode 100644 index 000000000..4e136d667 --- /dev/null +++ b/ui/admin/app/routes/scopes/scope/session-recordings/index.js @@ -0,0 +1,3 @@ +import Route from '@ember/routing/route'; + +export default class ScopesScopeSessionRecordingsIndexRoute extends Route {} diff --git a/ui/admin/app/routes/scopes/scope/session-recordings/session-recording.js b/ui/admin/app/routes/scopes/scope/session-recordings/session-recording.js new file mode 100644 index 000000000..97c93feb0 --- /dev/null +++ b/ui/admin/app/routes/scopes/scope/session-recordings/session-recording.js @@ -0,0 +1,31 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +export default class ScopesScopeSessionRecordingsSessionRecordingRoute extends Route { + // =services + @service store; + @service session; + @service router; + + // =methods + + /** + * If arriving here unauthenticated, redirect to index for further processing. + */ + beforeModel() { + if (!this.session.isAuthenticated) this.router.transitionTo('index'); + } + + /** + * Load session recording. + * @return {sessionRecording: SessionRecordingModel} + */ + async model({ session_recording_id }) { + const sessionRecording = await this.store.findRecord( + 'session-recording', + session_recording_id + ); + + return sessionRecording; + } +} diff --git a/ui/admin/app/routes/scopes/scope/session-recordings/session-recording/channels-by-connection.js b/ui/admin/app/routes/scopes/scope/session-recordings/session-recording/channels-by-connection.js new file mode 100644 index 000000000..c19452b68 --- /dev/null +++ b/ui/admin/app/routes/scopes/scope/session-recordings/session-recording/channels-by-connection.js @@ -0,0 +1,46 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +export default class ScopesScopeSessionRecordingsSessionRecordingChannelsByConnectionRoute extends Route { + // =services + @service store; + @service session; + + // =methods + + /** + * If arriving here unauthenticated, redirect to index for further processing. + */ + beforeModel() { + if (!this.session.isAuthenticated) this.router.transitionTo('index'); + } + + /** + * Load session recording (already in store) and related storage bucket if present + * @return {{sessionRecording: SessionRecordingModel, storageBucket: ?StorageBucketModel}} + */ + async model() { + let storageBucket = null; + + const sessionRecording = this.modelFor( + 'scopes.scope.session-recordings.session-recording' + ); + + try { + if (sessionRecording.storage_bucket_id) { + const { storage_bucket_id } = sessionRecording; + storageBucket = await this.store.findRecord( + 'storage-bucket', + storage_bucket_id + ); + } + } catch (e) { + // no op + } + + return { + sessionRecording, + storageBucket, + }; + } +} diff --git a/ui/admin/app/routes/scopes/scope/session-recordings/session-recording/channels-by-connection/channel.js b/ui/admin/app/routes/scopes/scope/session-recordings/session-recording/channels-by-connection/channel.js new file mode 100644 index 000000000..60dd486cb --- /dev/null +++ b/ui/admin/app/routes/scopes/scope/session-recordings/session-recording/channels-by-connection/channel.js @@ -0,0 +1,29 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +export default class ScopesScopeSessionRecordingsSessionRecordingChannelsByConnectionChannelRoute extends Route { + // =services + @service store; + + // =methods + /** + * + * @param {string} channel_id + * @returns {channelRecording: Object, sessionRecording: Object, storageBucket: Object} + */ + model({ channel_id }) { + const { sessionRecording, storageBucket } = this.modelFor( + 'scopes.scope.session-recordings.session-recording.channels-by-connection' + ); + const channelRecording = this.store.peekRecord( + 'channel-recording', + channel_id + ); + + return { + channelRecording, + sessionRecording, + storageBucket, + }; + } +} diff --git a/ui/admin/app/routes/scopes/scope/session-recordings/session-recording/channels-by-connection/channel/index.js b/ui/admin/app/routes/scopes/scope/session-recordings/session-recording/channels-by-connection/channel/index.js new file mode 100644 index 000000000..18d552c72 --- /dev/null +++ b/ui/admin/app/routes/scopes/scope/session-recordings/session-recording/channels-by-connection/channel/index.js @@ -0,0 +1,29 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +export default class ScopesScopeSessionRecordingsSessionRecordingChannelsByConnectionChannelIndexRoute extends Route { + // =services + @service can; + + // =methods + /** + * @returns {channelRecording: Object, sessionRecording: Object, storageBucket: Object, asciicast: Object} + */ + async model() { + let asciicast; + const { sessionRecording, storageBucket, channelRecording } = this.modelFor( + 'scopes.scope.session-recordings.session-recording.channels-by-connection.channel' + ); + + if (this.can.can('getAsciicast channel-recording', channelRecording)) { + asciicast = await channelRecording.getAsciicast(); + } + + return { + channelRecording, + sessionRecording, + storageBucket, + asciicast, + }; + } +} diff --git a/ui/admin/app/routes/scopes/scope/session-recordings/session-recording/channels-by-connection/index.js b/ui/admin/app/routes/scopes/scope/session-recordings/session-recording/channels-by-connection/index.js new file mode 100644 index 000000000..2a6ddf3a4 --- /dev/null +++ b/ui/admin/app/routes/scopes/scope/session-recordings/session-recording/channels-by-connection/index.js @@ -0,0 +1,3 @@ +import Route from '@ember/routing/route'; + +export default class ScopesScopeSessionRecordingsSessionRecordingChannelsByConnectionIndexRoute extends Route {} diff --git a/ui/admin/app/routes/scopes/scope/session-recordings/session-recording/index.js b/ui/admin/app/routes/scopes/scope/session-recordings/session-recording/index.js new file mode 100644 index 000000000..3d08a77ad --- /dev/null +++ b/ui/admin/app/routes/scopes/scope/session-recordings/session-recording/index.js @@ -0,0 +1,23 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +export default class ScopesScopeSessionRecordingsSessionRecordingIndexRoute extends Route { + // =services + @service router; + + // =methods + + /** + * Redirects to the sessionRecording type specific screen, + * for now just SSH types are supported. + * @param {SessionRecordingModel} sessionRecording + */ + async afterModel(sessionRecording) { + if (sessionRecording.isSSH) { + this.router.transitionTo( + 'scopes.scope.session-recordings.session-recording.channels-by-connection', + sessionRecording.id + ); + } + } +} diff --git a/ui/admin/app/routes/scopes/scope/storage-buckets.js b/ui/admin/app/routes/scopes/scope/storage-buckets.js new file mode 100644 index 000000000..730cb6a26 --- /dev/null +++ b/ui/admin/app/routes/scopes/scope/storage-buckets.js @@ -0,0 +1,91 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { loading } from 'ember-loading'; +import { confirm } from 'core/decorators/confirm'; +import { notifySuccess, notifyError } from 'core/decorators/notify'; + +export default class ScopesScopeStorageBucketsRoute extends Route { + // =services + + @service store; + @service session; + @service can; + @service router; + + // =methods + + /** + * If arriving here unauthenticated, redirect to index for further processing. + */ + beforeModel() { + if (!this.session.isAuthenticated) this.router.transitionTo('index'); + } + + /** + * Load all storage buckets under global and org scopes. + * @return {Promise<[StorageBucketModel]>} + */ + async model() { + const scope = this.modelFor('scopes.scope'); + const { id: scope_id } = scope; + if ( + this.can.can('list scope', scope, { + collection: 'storage-buckets', + }) + ) { + return this.store.query('storage-bucket', { scope_id, recursive: true }); + } + } + + // =actions + + /** + * Handle save + * @param {StorageBucketModel} storageBucket + */ + @action + @loading + @notifyError(({ message }) => message) + @notifySuccess('notifications.save-success') + async save(storageBucket) { + await storageBucket.save(); + await this.router.transitionTo( + 'scopes.scope.storage-buckets.storage-bucket', + storageBucket + ); + this.refresh(); + } + + /** + * Rollback changes on storage buckets. + * @param {StorageBucketModel} storageBucket + */ + @action + cancel(storageBucket) { + const { isNew } = storageBucket; + storageBucket.rollbackAttributes(); + if (isNew) this.router.transitionTo('scopes.scope.storage-buckets'); + } + + /** + * Deletes the storage bucket. + * @param {StorageBucketModel} storageBucket + */ + @action + @loading + @confirm('resources.storage-bucket.questions.delete-storage-bucket.message', { + title: 'resources.storage-bucket.questions.delete-storage-bucket.title', + confirm: 'resources.storage-bucket.actions.delete', + }) + @notifyError(({ message }) => message, { catch: true }) + @notifySuccess('notifications.delete-success') + async delete(storageBucket) { + await storageBucket.destroyRecord(); + } +} diff --git a/ui/admin/app/routes/scopes/scope/storage-buckets/index.js b/ui/admin/app/routes/scopes/scope/storage-buckets/index.js new file mode 100644 index 000000000..1796e2de3 --- /dev/null +++ b/ui/admin/app/routes/scopes/scope/storage-buckets/index.js @@ -0,0 +1,16 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Route from '@ember/routing/route'; + +export default class ScopesScopeStorageBucketsIndexRoute extends Route { + // =methods + + setupController(controller) { + const scope = this.modelFor('scopes.scope'); + super.setupController(...arguments); + controller.setProperties({ scope }); + } +} diff --git a/ui/admin/app/routes/scopes/scope/storage-buckets/new.js b/ui/admin/app/routes/scopes/scope/storage-buckets/new.js new file mode 100644 index 000000000..080365109 --- /dev/null +++ b/ui/admin/app/routes/scopes/scope/storage-buckets/new.js @@ -0,0 +1,54 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; +import { + TYPE_STORAGE_BUCKET_PLUGIN, + TYPE_STORAGE_BUCKET_PLUGIN_AWS_S3, +} from 'api/models/storage-bucket'; + +export default class ScopesScopeStorageBucketsNewRoute extends Route { + // =services + + @service store; + + // =methods + + /** + * Creates a new unsaved storage bucket. + * @return {StorageBucket} + */ + model() { + const scopeModel = this.store.peekRecord('scope', 'global'); + const record = this.store.createRecord('storage-bucket', { + type: TYPE_STORAGE_BUCKET_PLUGIN, + compositeType: TYPE_STORAGE_BUCKET_PLUGIN_AWS_S3, + }); + record.scopeModel = scopeModel; + return record; + } + + async afterModel() { + let scopes; + const orgScopes = ( + await this.store.query('scope', { scope_id: 'global' }) + ).map((scope) => ({ model: scope })); + scopes = [ + { model: this.store.peekRecord('scope', 'global') }, + ...orgScopes, + ]; + this.scopes = scopes; + } + + /** + * Adds available global and org scopes to the context. + * @param {Controller} controller + */ + setupController(controller) { + super.setupController(...arguments); + controller.set('scopes', this.scopes); + } +} diff --git a/ui/admin/app/routes/scopes/scope/storage-buckets/storage-bucket.js b/ui/admin/app/routes/scopes/scope/storage-buckets/storage-bucket.js new file mode 100644 index 000000000..ac33f0f36 --- /dev/null +++ b/ui/admin/app/routes/scopes/scope/storage-buckets/storage-bucket.js @@ -0,0 +1,25 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +export default class ScopesScopeStorageBucketsStorageBucketRoute extends Route { + // =services + + @service store; + + // =methods + + /** + * Load a user in current scope. + * @param {object} params + * @param {string} params.storage_bucket_id + * @return {StorageBucketModel} + */ + async model({ storage_bucket_id }) { + return this.store.findRecord('storage-bucket', storage_bucket_id); + } +} diff --git a/ui/admin/app/routes/scopes/scope/storage-buckets/storage-bucket/index.js b/ui/admin/app/routes/scopes/scope/storage-buckets/storage-bucket/index.js new file mode 100644 index 000000000..e2a7c1f89 --- /dev/null +++ b/ui/admin/app/routes/scopes/scope/storage-buckets/storage-bucket/index.js @@ -0,0 +1,36 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +export default class ScopesScopeStorageBucketsStorageBucketIndexRoute extends Route { + // =services + + @service store; + + // =methods + + async afterModel() { + let scopes; + const orgScopes = ( + await this.store.query('scope', { scope_id: 'global' }) + ).map((scope) => ({ model: scope })); + scopes = [ + { model: this.store.peekRecord('scope', 'global') }, + ...orgScopes, + ]; + this.scopes = scopes; + } + + /** + * Adds available global and org scopes to the context. + * @param {Controller} controller + */ + setupController(controller) { + super.setupController(...arguments); + controller.set('scopes', this.scopes); + } +} diff --git a/ui/admin/app/routes/scopes/scope/targets/target/enable-session-recording.js b/ui/admin/app/routes/scopes/scope/targets/target/enable-session-recording.js new file mode 100644 index 000000000..06850fe1f --- /dev/null +++ b/ui/admin/app/routes/scopes/scope/targets/target/enable-session-recording.js @@ -0,0 +1,65 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; + +export default class ScopesScopeTargetsTargetEnableSessionRecordingRoute extends Route { + // =services + @service store; + @service router; + + // =methods + + /** + * Returns the current target + * @return {TargetModel} + */ + + model() { + return this.modelFor('scopes.scope.targets.target'); + } + + /** + * Load storage buckets from target's project scope and global scope + * @param {Model} model + */ + async afterModel() { + const { scopeID: scope_id } = this.modelFor('scopes.scope'); + + //fetch storage buckets from global scope + const globalScopeStorageBuckets = await this.store.query('storage-bucket', { + scope_id: 'global', + }); + + //fetch storage buckets from target's org scope + const orgScopeStorageBuckets = await this.store.query('storage-bucket', { + scope_id, + }); + const storageBucketList = [ + ...globalScopeStorageBuckets.toArray(), + ...orgScopeStorageBuckets.toArray(), + ]; + + this.storageBucketList = storageBucketList; + } + // =actions + + /** + * Reset the selected storage bucket and redirect to target + * @param {TargetModel} target + */ + @action + cancel(target) { + const { id } = target; + target.rollbackAttributes(); + this.router.replaceWith('scopes.scope.targets.target', id); + } + + /** + * Adds `storageBucketList` to the context. + * @param {Controller} controller + */ + setupController(controller) { + super.setupController(...arguments); + controller.set('storageBucketList', this.storageBucketList); + } +} diff --git a/ui/admin/app/routes/scopes/scope/targets/target/index.js b/ui/admin/app/routes/scopes/scope/targets/target/index.js index 5a77edb1a..3b7c4a5f0 100644 --- a/ui/admin/app/routes/scopes/scope/targets/target/index.js +++ b/ui/admin/app/routes/scopes/scope/targets/target/index.js @@ -4,5 +4,30 @@ */ import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; -export default class ScopesScopeTargetsTargetIndexRoute extends Route {} +export default class ScopesScopeTargetsTargetIndexRoute extends Route { + // =services + + @service store; + + // =methods + + /** + * Adds storage bucket name to the context. + * @param {Controller} controller + * @param {TargetModel} target + */ + async setupController(controller, target) { + super.setupController(...arguments); + if (target?.storage_bucket_id) { + const { storage_bucket_id } = target; + const storageBucket = await this.store.findRecord( + 'storage-bucket', + storage_bucket_id + ); + const storage_bucket_name = storageBucket.displayName; + controller.set('storage_bucket_name', storage_bucket_name); + } + } +} diff --git a/ui/admin/app/styles/app.scss b/ui/admin/app/styles/app.scss index e90bda620..24162d53c 100644 --- a/ui/admin/app/styles/app.scss +++ b/ui/admin/app/styles/app.scss @@ -9,6 +9,7 @@ @import 'rose'; @import 'notify'; +@import '@hashicorp/ember-asciinema-player'; .rose-layout-global { .rose-layout-global-body { @@ -642,6 +643,64 @@ } } +.enable-session-recording-toggle { + width: 50%; + margin-top: 1.5rem; + + .hds-form-field--layout-vertical, + .hds-link-standalone { + margin-left: 2.5rem; + } + + .hds-link-standalone { + margin-top: 0.75rem; + margin-bottom: 1.5rem; + } +} + +// target sidebar +.target-sidebar { + .description { + color: var(--token-form-helper-text-color); + font-weight: var(--token-typography-font-weight-normal); + font-size: var(--token-typography-body-200-font-size); + } + + .hds-link-standalone { + margin-top: 1rem; + } +} + +.link-list-item { + display: flex; + align-items: center; + padding: 1rem; + + a { + text-decoration: none; + align-items: center; + display: flex; + width: 100%; + } + + &__info { + display: flex; + width: 100%; + align-items: center; + margin-left: 0.25rem; + gap: 0.75rem; + } + + &__text { + color: var(--token-color-foreground-primary); + margin-bottom: 0.25rem; + } + + .flight-icon-arrow-right { + color: var(--token-color-foreground-faint); + } +} + .key-value-list { border: 0; @@ -687,3 +746,247 @@ .auth-methods-list-table { margin-top: 1rem; } + +// secret input field +.secret-input { + display: flex; + align-items: flex-end; + column-gap: 0.5rem; + position: relative; + + .overlay { + width: 60%; + height: 1rem; + position: absolute; + bottom: 2.15rem; + left: 0.5rem; + border-radius: 0.25rem; + background-color: var(--token-color-palette-neutral-200); + } + + .hds-button { + margin-bottom: 1.5rem; + flex-basis: 6rem; + } +} + +// session recording player +.session-recording-player { + --player-background-color: #252937; + --player-divider: #656a761a; + --player-white: #fff; + --player-control-bar-buffer: #656a76; + --player-play-button: #1060ff; + --player-play-button-border: #0c56e9; + + background-color: var(--player-background-color); + border-radius: 0.75rem; + + &-header { + border-bottom: var(--player-divider) 0.125rem solid; + + // override of button styles to allow for + // static colors in light and dark mode + a { + color: var(--player-white); + padding: 1rem; + } + + .hds-button--color-tertiary:hover, + .hds-button--color-tertiary:active { + color: var(--player-white); + background-color: transparent; + border-color: transparent; + } + } + + &-loading { + color: var(--token-color-palette-neutral-400); + display: flex; + gap: 1rem; + justify-content: center; + align-items: center; + height: 75vh; + } + + &-theme { + .asciinema-theme-asciinema { + background-color: var(--player-background-color); + } + + pre.ap-terminal { + background-color: var(--player-background-color); + border-color: var(--player-background-color); + } + + .ap-player { + border-radius: 0.75rem; + + // hides large play icon overlay + .ap-overlay { + display: none; + } + } + + .ap-wrapper { + &.ap-hud .ap-control-bar { + bottom: 0; + } + + .ap-control-bar { + bottom: -3.625rem; + background: var(--player-background-color); + border-top: var(--player-divider) 0.125rem solid; + height: 3.625rem; + + .ap-playback-button { + background: var(--player-play-button); + border: var(--player-play-button-border) 0.063rem solid; + border-radius: 0.313rem; + margin: 0.75rem; + padding: 0.625rem 1rem; + + .ap-icon path { + fill: var(--player-white); + } + } + } + + .ap-timer { + font-family: var(--token-typography-body-base-font-family); + font-size: var(--token-typography-body-base-font-size); + line-height: var(--token-typography-body-base-line-height); + color: var(--player-white); + position: relative; + top: 40%; + } + + .ap-progressbar { + padding-top: 0.875rem; + + .ap-bar .ap-gutter { + background-color: var(--player-control-bar-buffer); + + span { + background-color: var(--player-white); + } + } + } + + .ap-fullscreen-button { + padding-top: 1.5rem; + + .ap-icon path { + fill: var(--player-white); + } + } + } + } +} + +// Layout - Sidebar +.layout-sidebar { + display: grid; + grid-template-areas: 'body body sidebar'; + grid-template-columns: 1fr 1fr 300px; + grid-template-rows: auto 1fr; + gap: 2rem; + + .layout-sidebar-body { + grid-area: body; + } + + .layout-sidebar-sidebar { + grid-area: sidebar; + + &-details { + margin-bottom: 3rem; + } + } +} + +@media (max-width: media.width(large)) { + .layout-sidebar { + grid-template-areas: + 'body' + 'sidebar'; + grid-template-columns: auto; + + .layout-sidebar-body { + min-width: 0; + } + + .layout-sidebar-sidebar { + max-width: 300px; + } + } +} + +// Session recording - List +.session-recording-list-cell-item { + display: flex; + gap: 0.5rem; + + span { + display: flex; + align-items: center; + } +} + +.session-recording-list-cell-item.target-column { + flex-direction: column; + + span { + gap: 0.5rem; + } +} + +// Channels by Connection +.channels-by-connection { + &-connection-header { + padding: 0.875rem 1rem; + } + + .no-channels-message { + padding: 0 1rem; + } +} + +// Channel +.channel-header { + margin-bottom: 1rem; + + h2 { + margin-bottom: 0; + } +} + +// Metadata List +.metadata-list { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 1rem; + + &.vertical { + flex-direction: column; + row-gap: 1rem; + } + + .metadata-list-item { + display: flex; + align-items: center; + column-gap: 0.3rem; + } +} + +// Session Recording Status +// overwrites recording badge icon to red +.session-recording-status-badge { + /* stylelint-disable selector-class-pattern */ + div.hds-badge__icon { + svg.flight-icon-circle-dot { + color: var(--token-color-palette-red-200); + } + } +} diff --git a/ui/admin/app/templates/scopes/scope.hbs b/ui/admin/app/templates/scopes/scope.hbs index 2c72e7ddd..0c63946ab 100644 --- a/ui/admin/app/templates/scopes/scope.hbs +++ b/ui/admin/app/templates/scopes/scope.hbs @@ -26,23 +26,35 @@ {{t 'resources.org.title_plural'}} - - {{#if - (and - (can 'navigate worker' @model collection='workers') - (feature-flag 'byow') - ) - }} - + + {{#if (can 'navigate scope' @model collection='session-recordings')}} + + + {{t 'resources.session-recording.title_plural'}} + + {{/if}} + {{#if (can 'navigate scope' @model collection='storage-buckets')}} + + + {{t 'resources.storage-bucket.title_plural'}} + + {{/if}} + + {{#if + (and + (can 'navigate worker' @model collection='workers') + (feature-flag 'byow') + ) + }} {{t 'resources.worker.title_plural'}} - - {{/if}} + {{/if}} + {{/if}} {{#if @model.isOrg}} diff --git a/ui/admin/app/templates/scopes/scope/session-recordings.hbs b/ui/admin/app/templates/scopes/scope/session-recordings.hbs new file mode 100644 index 000000000..8a94562e7 --- /dev/null +++ b/ui/admin/app/templates/scopes/scope/session-recordings.hbs @@ -0,0 +1,2 @@ +{{page-title (t 'resources.session-recording.title_plural')}} +{{outlet}} \ No newline at end of file diff --git a/ui/admin/app/templates/scopes/scope/session-recordings/index.hbs b/ui/admin/app/templates/scopes/scope/session-recordings/index.hbs new file mode 100644 index 000000000..979ca6eda --- /dev/null +++ b/ui/admin/app/templates/scopes/scope/session-recordings/index.hbs @@ -0,0 +1,126 @@ + + + + + + +

{{t 'resources.session-recording.title_plural'}} + +

+

{{t 'resources.session-recording.description'}}

+
+ + + {{#if this.hasSessionRecordings}} + + <:body as |B|> + + {{! Created Time }} + + {{#if B.data.created_time}} + + + + {{/if}} + + {{! Status }} + + + + {{! User }} + +
+ + {{B.data.userDisplayName}} +
+
+ {{! Target }} + +
+ + + {{B.data.create_time_values.target.name}} + + + + +
+
+ {{! Duration }} + + {{#if B.data.duration}} +
+ + + {{format-time-duration B.data.duration}} + +
+ {{/if}} +
+ + {{#if (can 'read session-recording' B.data)}} + + {{/if}} + +
+ +
+ {{else}} + + + + {{#if this.hasSessionRecordingsConfigured}} + {{! storage buckets exist but no recordings }} + {{t 'resources.session-recording.messages.none.description'}} + {{else}} + {{! No storage buckets exist }} + {{t + 'resources.session-recording.messages.none.no-storage-bucket-description' + }} + {{/if}} + + {{#unless this.hasSessionRecordingsConfigured}} + + {{/unless}} + + + {{/if}} +
+
\ No newline at end of file diff --git a/ui/admin/app/templates/scopes/scope/session-recordings/session-recording.hbs b/ui/admin/app/templates/scopes/scope/session-recordings/session-recording.hbs new file mode 100644 index 000000000..f6557cda4 --- /dev/null +++ b/ui/admin/app/templates/scopes/scope/session-recordings/session-recording.hbs @@ -0,0 +1,2 @@ +{{page-title @model.displayName}} +{{outlet}} \ No newline at end of file diff --git a/ui/admin/app/templates/scopes/scope/session-recordings/session-recording/channels-by-connection.hbs b/ui/admin/app/templates/scopes/scope/session-recordings/session-recording/channels-by-connection.hbs new file mode 100644 index 000000000..5915f218c --- /dev/null +++ b/ui/admin/app/templates/scopes/scope/session-recordings/session-recording/channels-by-connection.hbs @@ -0,0 +1,3 @@ +{{page-title (t 'resources.session-recording.channel.title_plural')}} + +{{outlet}} \ No newline at end of file diff --git a/ui/admin/app/templates/scopes/scope/session-recordings/session-recording/channels-by-connection/channel.hbs b/ui/admin/app/templates/scopes/scope/session-recordings/session-recording/channels-by-connection/channel.hbs new file mode 100644 index 000000000..c7a3729b4 --- /dev/null +++ b/ui/admin/app/templates/scopes/scope/session-recordings/session-recording/channels-by-connection/channel.hbs @@ -0,0 +1,159 @@ +{{page-title (t 'resources.session-recording.channel.title')}} + + + + + + + +

+ {{indexed-display-name + 'resources.session-recording.channel.title_index' + @model.channelRecording.connection_recording.channel_recordings + @model.channelRecording + }} +

+ + {{@model.channelRecording.id}} + +
+ + + + + {{outlet}} + + + + {{! Channel details information }} +
+

{{t 'resources.session-recording.channel.details'}}

+ + {{! Start time }} + {{#if @model.channelRecording.start_time}} + + {{t 'form.started.label'}} + + + {{/if}} + {{! Duration }} + {{#if @model.channelRecording.duration}} + + {{t + 'resources.session-recording.duration' + time=(format-time-duration @model.channelRecording.duration) + }} + + {{/if}} + {{! Bytes up }} + {{#if @model.channelRecording.bytes_up}} + + {{t + 'resources.session-recording.up' + bytes=(format-bytes-size @model.channelRecording.bytes_up) + }} + + {{/if}} + {{! Bytes down }} + {{#if @model.channelRecording.bytes_down}} + + {{t + 'resources.session-recording.down' + bytes=(format-bytes-size @model.channelRecording.bytes_down) + }} + + {{/if}} + +
+ + {{! Related information }} +
+

{{t 'resources.session-recording.related'}}

+ + {{! User name }} + {{#if @model.sessionRecording.create_time_values.user}} + + {{/if}} + {{! Target name }} + {{#if @model.sessionRecording.create_time_values.target}} + + {{#if @model.sessionRecording.target.type}} + + {{/if}} + + {{/if}} + {{! Bucket name }} + {{#if @model.storageBucket}} + + {{/if}} + {{! Session Recording ID }} + + + {{@model.sessionRecording.id}} + + + {{! Connection Recording ID }} + + + {{@model.channelRecording.connection_recording.id}} + + + +
+
+
+
+
\ No newline at end of file diff --git a/ui/admin/app/templates/scopes/scope/session-recordings/session-recording/channels-by-connection/channel/index.hbs b/ui/admin/app/templates/scopes/scope/session-recordings/session-recording/channels-by-connection/channel/index.hbs new file mode 100644 index 000000000..3ac73d930 --- /dev/null +++ b/ui/admin/app/templates/scopes/scope/session-recordings/session-recording/channels-by-connection/channel/index.hbs @@ -0,0 +1,48 @@ +{{#if @model.asciicast}} + +{{else}} + + {{#if (can 'getAsciicast channel-recording' @model.channelRecording)}} + {{! Was not able to download asciicast }} + + + {{else}} + {{#if @model.channelRecording.isAsciicast}} + {{! Not authorized to download asciicast }} + + + {{else}} + {{! Playback not supported }} + + + {{/if}} + {{/if}} + +{{/if}} \ No newline at end of file diff --git a/ui/admin/app/templates/scopes/scope/session-recordings/session-recording/channels-by-connection/channel/loading.hbs b/ui/admin/app/templates/scopes/scope/session-recordings/session-recording/channels-by-connection/channel/loading.hbs new file mode 100644 index 000000000..2918ed16a --- /dev/null +++ b/ui/admin/app/templates/scopes/scope/session-recordings/session-recording/channels-by-connection/channel/loading.hbs @@ -0,0 +1,9 @@ + +
+ + {{t 'resources.session-recording.channel.player.loading'}} +
+
\ No newline at end of file diff --git a/ui/admin/app/templates/scopes/scope/session-recordings/session-recording/channels-by-connection/index.hbs b/ui/admin/app/templates/scopes/scope/session-recordings/session-recording/channels-by-connection/index.hbs new file mode 100644 index 000000000..241b99768 --- /dev/null +++ b/ui/admin/app/templates/scopes/scope/session-recordings/session-recording/channels-by-connection/index.hbs @@ -0,0 +1,283 @@ + + + + + + +

{{t 'resources.session-recording.session.title'}}

+
+ + +
+ + + {{#if this.isSessionInprogressWithNoConnections}} + + {{! Session recording in progress with no connections }} + + + + {{else if this.isSessionUnknownWithNoConnections}} + + {{! Session recording unknown with no connections }} + + + + {{else}} + {{#each + @model.sessionRecording.connection_recordings + as |connection| + }} +
+

+ {{indexed-display-name + 'resources.session-recording.connection.title_index' + @model.sessionRecording.connection_recordings + connection + }} +

+ + {{connection.id}} + +
+ {{#if connection.channel_recordings}} + + <:body as |B|> + + + + {{indexed-display-name + 'resources.session-recording.channel.title_index' + connection.channel_recordings + B.data + }} + + + + + {{! Bytes up }} + {{#if B.data.bytes_up}} + + {{t + 'resources.session-recording.up' + bytes=(format-bytes-size B.data.bytes_up) + }} + + {{/if}} + {{! Bytes down }} + {{#if B.data.bytes_down}} + + {{t + 'resources.session-recording.down' + bytes=(format-bytes-size B.data.bytes_down) + }} + + {{/if}} + {{! Duration }} + {{#if B.data.duration}} + + {{format-time-duration B.data.duration}} + + {{/if}} + + + + + {{B.data.id}} + + + + {{#if @model.sessionRecording.isAvailable}} + + {{/if}} + + + + + {{else}} +
+ + + {{t + 'resources.session-recording.channels-by-connection.messages.no-channels' + }} + + +
+ {{/if}} + {{/each}} + {{/if}} +
+ +
+

{{t 'resources.session-recording.details'}}

+ + + {{! Session Recording Status }} + {{#if @model.sessionRecording.state}} + + + + {{/if}} + {{! Created time }} + {{#if @model.sessionRecording.created_time}} + + {{t 'form.created.label'}} + + + {{/if}} + {{! Duration }} + {{#if @model.sessionRecording.duration}} + + {{t + 'resources.session-recording.duration' + time=(format-time-duration @model.sessionRecording.duration) + }} + + {{/if}} + {{! Bytes up }} + {{#if @model.sessionRecording.bytes_up}} + + {{t + 'resources.session-recording.up' + bytes=(format-bytes-size @model.sessionRecording.bytes_up) + }} + + {{/if}} + {{! Bytes down }} + {{#if @model.sessionRecording.bytes_down}} + + {{t + 'resources.session-recording.down' + bytes=(format-bytes-size @model.sessionRecording.bytes_down) + }} + + {{/if}} + +
+ + {{! Related information }} +
+

{{t 'resources.session-recording.related'}}

+ + {{! User name }} + {{#if @model.sessionRecording.create_time_values.user}} + + {{/if}} + {{! Target name }} + {{#if @model.sessionRecording.create_time_values.target}} + + {{#if @model.sessionRecording.create_time_values.target.type}} + + {{/if}} + + {{/if}} + {{! Bucket name }} + {{#if @model.storageBucket}} + + {{/if}} + {{! Session Recording ID }} + + + {{@model.sessionRecording.id}} + + + +
+
+
+
+
+
\ No newline at end of file diff --git a/ui/admin/app/templates/scopes/scope/session-recordings/session-recording/index.hbs b/ui/admin/app/templates/scopes/scope/session-recordings/session-recording/index.hbs new file mode 100644 index 000000000..e69de29bb diff --git a/ui/admin/app/templates/scopes/scope/storage-buckets.hbs b/ui/admin/app/templates/scopes/scope/storage-buckets.hbs new file mode 100644 index 000000000..6067d7abe --- /dev/null +++ b/ui/admin/app/templates/scopes/scope/storage-buckets.hbs @@ -0,0 +1,8 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} + +{{page-title (t 'resources.storage-bucket.title_plural')}} + +{{outlet}} \ No newline at end of file diff --git a/ui/admin/app/templates/scopes/scope/storage-buckets/index.hbs b/ui/admin/app/templates/scopes/scope/storage-buckets/index.hbs new file mode 100644 index 000000000..0b71a2252 --- /dev/null +++ b/ui/admin/app/templates/scopes/scope/storage-buckets/index.hbs @@ -0,0 +1,164 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} + + + + + + + +

+ {{t 'resources.storage-bucket.title_plural'}} + +

+

{{t 'resources.storage-bucket.description'}}

+
+ + + {{#if (can 'create scope' this.scope collection='storage-buckets')}} + {{#if @model}} + + {{/if}} + {{/if}} + + + + {{#if @model}} + + <:body as |B|> + + + {{#if (can 'read storage-bucket' B.data)}} + + {{B.data.displayName}} + + {{else}} + {{B.data.displayName}} + {{/if}} + {{#if B.data.description}} +

{{B.data.description}}

+ {{/if}} +
+ + + {{B.data.id}} + + + + {{#if B.data.isAWS}} + + {{else}} + {{B.data.plugin.name}} + {{/if}} + + + {{#if B.data.scopeModel.isGlobal}} + + {{else}} + + {{/if}} + + + {{#let + (hash + canRead=(can 'read storage-bucket' B.data) + canDelete=(can 'delete storage-bucket' B.data) + ) + as |perms| + }} + {{#if (or perms.canRead perms.canDelete)}} + + + {{#if perms.canRead}} + + {{/if}} + {{#if (and perms.canRead perms.canDelete)}} + + {{/if}} + {{#if perms.canDelete}} + + {{/if}} + + {{/if}} + {{/let}} + +
+ +
+ {{else}} + + + + {{#if (can 'list scope' this.scope collection='storage-buckets')}} + {{t 'resources.storage-bucket.messages.none.description'}} + {{else if (can + 'create scope' this.scope collection='storage-buckets' + )}} + {{t + 'descriptions.create-but-not-list' + resource=(t 'resources.storage-bucket.title_plural') + }} + {{else}} + {{t + 'descriptions.neither-list-nor-create' + resource=(t 'resources.storage-bucket.title_plural') + }} + {{/if}} + + {{#if (can 'create scope' this.scope collection='storage-buckets')}} + + {{/if}} + + + {{/if}} +
+
\ No newline at end of file diff --git a/ui/admin/app/templates/scopes/scope/storage-buckets/new.hbs b/ui/admin/app/templates/scopes/scope/storage-buckets/new.hbs new file mode 100644 index 000000000..e1b859492 --- /dev/null +++ b/ui/admin/app/templates/scopes/scope/storage-buckets/new.hbs @@ -0,0 +1,30 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} + +{{page-title (t 'titles.new')}} + + + + + + + + {{! TODO: Add valid doc link and description}} +

+ {{t 'resources.storage-bucket.titles.new'}} + +

+
+ + + + + +
\ No newline at end of file diff --git a/ui/admin/app/templates/scopes/scope/storage-buckets/storage-bucket.hbs b/ui/admin/app/templates/scopes/scope/storage-buckets/storage-bucket.hbs new file mode 100644 index 000000000..40b29859d --- /dev/null +++ b/ui/admin/app/templates/scopes/scope/storage-buckets/storage-bucket.hbs @@ -0,0 +1,8 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} + +{{page-title @model.displayName}} + +{{outlet}} \ No newline at end of file diff --git a/ui/admin/app/templates/scopes/scope/storage-buckets/storage-bucket/index.hbs b/ui/admin/app/templates/scopes/scope/storage-buckets/storage-bucket/index.hbs new file mode 100644 index 000000000..1ce337527 --- /dev/null +++ b/ui/admin/app/templates/scopes/scope/storage-buckets/storage-bucket/index.hbs @@ -0,0 +1,41 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} + + + + + + + +

+ {{t 'resources.storage-bucket.title'}} + +

+ + {{@model.id}} + +
+ + + + + {{t 'titles.details'}} + + + + + + + +
\ No newline at end of file diff --git a/ui/admin/app/templates/scopes/scope/targets/target/enable-session-recording.hbs b/ui/admin/app/templates/scopes/scope/targets/target/enable-session-recording.hbs new file mode 100644 index 000000000..1137d12ea --- /dev/null +++ b/ui/admin/app/templates/scopes/scope/targets/target/enable-session-recording.hbs @@ -0,0 +1,23 @@ +{{page-title (t 'resources.target.actions.enable-session-recording')}} + + + + + + + +

{{t 'resources.target.enable-session-recording.messages.add.title'}} + +

+
+ + + + + +
\ No newline at end of file diff --git a/ui/admin/app/templates/scopes/scope/targets/target/index.hbs b/ui/admin/app/templates/scopes/scope/targets/target/index.hbs index 87cedc69b..bd19b42d5 100644 --- a/ui/admin/app/templates/scopes/scope/targets/target/index.hbs +++ b/ui/admin/app/templates/scopes/scope/targets/target/index.hbs @@ -22,11 +22,54 @@ - + + + + + {{#if (and @model.isSSH (feature-flag 'ssh-session-recording'))}} + +
+

{{t 'resources.target.form.sidebar.label'}}

+

+ {{t 'resources.target.form.sidebar.help'}} +

+ {{#if @model.enable_session_recording}} + + + + + + + {{else}} + + {{/if}} +
+
+ {{/if}} +
+
\ No newline at end of file diff --git a/ui/admin/config/environment.js b/ui/admin/config/environment.js index 577b7b6e3..d5d47ed8e 100644 --- a/ui/admin/config/environment.js +++ b/ui/admin/config/environment.js @@ -93,6 +93,7 @@ module.exports = function (environment) { target: '/targets', 'target.new': '/targets/new', 'target.add-host-sources': '/targets/add-host-sets', + 'target.enable-session-recording': '/targets/enable-session-recording', 'target.worker-filters': '/targets/worker-filters', user: '/users', downloads: '/downloads', @@ -102,6 +103,8 @@ module.exports = function (environment) { worker: '/workers', 'worker.manage-workers': '/workers/manage-workers-on-hcp', 'worker-filters': '/worker-filters', + 'storage-bucket': '/storage-buckets', + 'session-recording': '/session-recordings', }, }, diff --git a/ui/admin/config/features.js b/ui/admin/config/features.js index 435c83939..130b3cefa 100644 --- a/ui/admin/config/features.js +++ b/ui/admin/config/features.js @@ -13,6 +13,7 @@ const devFeatures = { const licensedFeatures = { 'multi-hop-sessions': false, 'ssh-target': false, + 'ssh-session-recording': false, }; // Base edition declares available features, disabled by default. const baseEdition = { diff --git a/ui/admin/package.json b/ui/admin/package.json index 1e7f567d6..5700ea196 100644 --- a/ui/admin/package.json +++ b/ui/admin/package.json @@ -52,6 +52,7 @@ "@faker-js/faker": "^7.3.0", "@glimmer/component": "^1.1.2", "@glimmer/tracking": "^1.1.2", + "@hashicorp/ember-asciinema-player": "https://github.com/hashicorp/ember-asciinema-player.git#e047a096039cff70234c232efe75dcad74c6358a", "@playwright/test": "^1.30.0", "babel-loader": "^8.2.5", "broccoli-asset-rev": "^3.0.0", diff --git a/ui/admin/public/session.cast b/ui/admin/public/session.cast new file mode 100644 index 000000000..698962492 --- /dev/null +++ b/ui/admin/public/session.cast @@ -0,0 +1,69 @@ +{"version": 2, "width": 124, "height": 36, "timestamp": 1670352594, "env": {"SHELL": "/bin/zsh", "TERM": "xterm-256color"}} +[2.138652, "o", "\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m \r \r\u001b]2;ember-asciinema-player\u0007\u001b]1;..iinema-player\u0007"] +[2.220877, "o", "\r\u001b[0m\u001b[27m\u001b[24m\u001b[J\u001b[33member-asciinema-player\u001b[00m \u001b[32mgit\u001b[00m:\u001b[36m(main*)\u001b[00m $ \u001b[K"] +[2.221019, "o", "\u001b[?1h\u001b="] +[2.221076, "o", "\u001b[?2004h"] +[2.676444, "o", "y"] +[2.788653, "o", "\bya"] +[2.868652, "o", "r"] +[3.001764, "o", "n"] +[3.167636, "o", " "] +[3.313668, "o", "t"] +[3.460527, "o", "e"] +[3.561983, "o", "s"] +[3.63678, "o", "t"] +[3.853437, "o", "\u001b[?1l\u001b>"] +[3.853473, "o", "\u001b[?2004l\r\r\n"] +[3.854604, "o", "\u001b]2;yarn test\u0007\u001b]1;yarn\u0007"] +[4.489178, "o", "\u001b[2K"] +[4.48982, "o", "\u001b[1G\u001b[1myarn run v1.22.19\u001b[22m\r\n"] +[4.555618, "o", "\u001b[2K"] +[4.555649, "o", "\u001b[1G\u001b[2m$ npm-run-all lint\u001b[22m\r\n"] +[5.345253, "o", "\u001b[2K"] +[5.345509, "o", "\u001b[1G\u001b[2m$ npm-run-all --print-name --aggregate-output --continue-on-error --parallel \"lint:!(fix)\"\u001b[22m\r\n"] +[7.225926, "o", "\r\n> ember-asciinema-player@0.0.0 lint:js ember-asciinema-player/package.json\r\n> eslint . --cache \r\n\r\n$ eslint . --cache\r\n"] +[8.398641, "o", "\r\n> ember-asciinema-player@0.0.0 lint:hbs ember-asciinema-player/package.json\r\n> ember-template-lint . \r\n\r\n$ ember-template-lint .\r\n\r\n"] +[8.418537, "o", "\u001b[2K\u001b[1G\u001b[2m$ ember test\u001b[22m\r\n"] +[12.256156, "o", "\u001b[?25l"] +[12.256464, "o", "\u001b[32m⠋\u001b[39m \u001b[32mBuilding\u001b[39m"] +[12.2573, "o", "\u001b[2K"] +[12.257386, "o", "\u001b[1G\u001b[?25h"] +[12.257689, "o", "Environment: test\r\n"] +[12.257857, "o", "\u001b[?25l\u001b[32m⠋\u001b[39m \u001b[32mBuilding\u001b[39m"] +[12.539035, "o", "\u001b[2K"] +[12.539144, "o", "\u001b[1G"] +[12.539219, "o", "\u001b[32m⠙\u001b[39m \u001b[32mbuilding... \u001b[39m"] +[12.745202, "o", "\u001b[2K\u001b[1G"] +[12.745343, "o", "\u001b[32m⠹\u001b[39m \u001b[32mbuilding... \u001b[39m[Babel: @ember/test-helpers > applyPatches]"] +[12.825004, "o", "\u001b[2K"] +[12.825031, "o", "\u001b[1G"] +[12.825122, "o", "\u001b[32m⠸\u001b[39m \u001b[32mbuilding... \u001b[39m[Babel: ember-qunit > applyPatches]"] +[12.933588, "o", "\u001b[2K\u001b[1G"] +[12.933624, "o", "\u001b[32m⠼\u001b[39m \u001b[32mbuilding... \u001b[39m[Babel: ember-load-initializers > applyPatches]"] +[13.194738, "o", "\u001b[2K\u001b[1G"] +[13.195009, "o", "\u001b[32m⠴\u001b[39m \u001b[32mbuilding... \u001b[39m[Babel: ember-asciinema-player > applyPatches]"] +[13.307878, "o", "\u001b[2K\u001b[1G\u001b[32m⠦\u001b[39m \u001b[32mbuilding... \u001b[39m[Package /assets/vendor.js]"] +[13.750717, "o", "\u001b[2K\u001b[1G"] +[13.750755, "o", "\u001b[32m⠧\u001b[39m \u001b[32mbuilding... \u001b[39m[Concat: Test Support JS]"] +[13.869411, "o", "\u001b[2K\u001b[1G"] +[13.869438, "o", "\u001b[32m⠇\u001b[39m \u001b[32mbuilding... \u001b[39m[ember-auto-import-webpack]"] +[14.655374, "o", "\u001b[2K\u001b[1G"] +[14.655415, "o", "\u001b[32m⠏\u001b[39m \u001b[32mbuilding... \u001b[39m[ember-auto-import-webpack]"] +[14.73686, "o", "\u001b[2K\u001b[1G\u001b[?25h"] +[14.738533, "o", "\u001b[?25h"] +[14.738818, "o", "\u001b[?25l\u001b[32m⠋\u001b[39m cleaning up"] +[14.739061, "o", "\u001b[2K\u001b[1G\u001b[?25hcleaning up...\r\n"] +[14.739197, "o", "\u001b[?25l\u001b[32m⠋\u001b[39m cleaning up"] +[14.907985, "o", "\u001b[2K"] +[14.90808, "o", "\u001b[1G\u001b[?25h"] +[14.908128, "o", "\u001b[32mBuilt project successfully.\u001b[39m\r\n"] +[16.224632, "o", "ok 1 Chrome 108.0 - [2 ms] - ember-qunit: Ember.onerror validation: Ember.onerror is functioning properly\r\n"] +[16.346568, "o", "\r\n1..1\r\n# tests 1\r\n# pass 1\r\n# skip 0\r\n# todo 0\r\n# fail 0\r\n\r\n# ok\r\n"] +[16.403167, "o", "\u001b[2K"] +[16.403388, "o", "\u001b[1G✨ Done in 11.92s.\r\n"] +[16.413967, "o", "\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m \r \r"] +[16.414147, "o", "\u001b]2;ember-asciinema-player\u0007\u001b]1;..iinema-player\u0007"] +[16.528341, "o", "\r\u001b[0m\u001b[27m\u001b[24m\u001b[J\u001b[33member-asciinema-player\u001b[00m \u001b[32mgit\u001b[00m:\u001b[36m(main*)\u001b[00m $ \u001b[K"] +[16.528501, "o", "\u001b[?1h\u001b="] +[16.52863, "o", "\u001b[?2004h"] +[19.078794, "o", "\u001b[?2004l\r\r\n"] \ No newline at end of file diff --git a/ui/admin/tests/acceptance/session-recordings/list-test.js b/ui/admin/tests/acceptance/session-recordings/list-test.js new file mode 100644 index 000000000..84876c190 --- /dev/null +++ b/ui/admin/tests/acceptance/session-recordings/list-test.js @@ -0,0 +1,90 @@ +import { module, test } from 'qunit'; +import { visit, currentURL, click } from '@ember/test-helpers'; +import { setupApplicationTest } from 'admin/tests/helpers'; +import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; +import { authenticateSession } from 'ember-simple-auth/test-support'; + +module('Acceptance | session recordings | list', function (hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + + let featuresService; + + // Selectors + const SESSION_RECORDING_TITLE = 'Session Recordings'; + + // Instances + const instances = { + scopes: { + global: null, + org: null, + }, + sessionRecording: null, + }; + + // Urls + const urls = { + globalScope: null, + sessionRecordings: null, + sessionRecording: null, + }; + + hooks.beforeEach(function () { + instances.scopes.global = this.server.create('scope', { id: 'global' }); + instances.scopes.org = this.server.create('scope', { + type: 'org', + scope: { id: 'global', type: 'global' }, + }); + instances.scopes.targetModel = this.server.create('target', { + scope: instances.scopes.global, + }); + instances.sessionRecording = this.server.create('session-recording', { + create_time_values: { + target: instances.scopes.targetModel.attrs, + }, + }); + urls.globalScope = `/scopes/global`; + urls.sessionRecordings = `${urls.globalScope}/session-recordings`; + urls.sessionRecording = `${urls.sessionRecordings}/${instances.sessionRecording.id}`; + + featuresService = this.owner.lookup('service:features'); + featuresService.enable('ssh-session-recording'); + + authenticateSession({}); + }); + + test('users can navigate to session-recordings with proper authorization', async function (assert) { + assert.expect(4); + + await visit(urls.globalScope); + + assert.true( + instances.scopes.global.authorized_collection_actions[ + 'session-recordings' + ].includes('list') + ); + assert.dom(`[href="${urls.sessionRecordings}"]`).exists(); + assert.dom('[title="General"]').includesText(SESSION_RECORDING_TITLE); + + // Visit session recordings + await click(`[href="${urls.sessionRecordings}"]`); + assert.strictEqual(currentURL(), urls.sessionRecordings); + }); + + test('users cannot navigate to session-recordings without the list action', async function (assert) { + assert.expect(3); + instances.scopes.global.authorized_collection_actions[ + 'session-recordings' + ] = []; + + await visit(urls.globalScope); + + assert.false( + instances.scopes.global.authorized_collection_actions[ + 'session-recordings' + ].includes('list') + ); + assert.dom('[title="General"]').doesNotIncludeText(SESSION_RECORDING_TITLE); + assert.dom(`[href="${urls.sessionRecordings}"]`).doesNotExist(); + }); +}); diff --git a/ui/admin/tests/acceptance/session-recordings/read-test.js b/ui/admin/tests/acceptance/session-recordings/read-test.js new file mode 100644 index 000000000..4e22ab1e5 --- /dev/null +++ b/ui/admin/tests/acceptance/session-recordings/read-test.js @@ -0,0 +1,111 @@ +import { module, test } from 'qunit'; +import { visit, currentURL, click } from '@ember/test-helpers'; +import { setupApplicationTest } from 'admin/tests/helpers'; +import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; +import a11yAudit from 'ember-a11y-testing/test-support/audit'; +import { authenticateSession } from 'ember-simple-auth/test-support'; + +module('Acceptance | session recordings | read', function (hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + + let featuresService; + + // Selectors + const LIST_SESSION_RECORDING_BUTTON = + 'table > tbody > tr > td:last-child > a'; + const LIST_CHANNEL_RECORDING_BUTTON = + 'table > tbody > tr > td:last-child > a'; + + // Instances + const instances = { + scopes: { + global: null, + org: null, + }, + sessionRecording: null, + }; + // Urls + const urls = { + globalScope: null, + sessionRecordings: null, + sessionRecording: null, + channelRecording: null, + }; + + hooks.beforeEach(function () { + instances.scopes.global = this.server.create('scope', { id: 'global' }); + instances.scopes.org = this.server.create('scope', { + type: 'org', + scope: { id: 'global', type: 'global' }, + }); + instances.target = this.server.create('target', { + scope: instances.scopes.global, + }); + instances.sessionRecording = this.server.create('session-recording', { + scope: instances.scopes.global, + create_time_values: { + target: instances.target.attrs, + }, + }); + instances.connectionRecording = this.server.create('connection-recording', { + session_recording: instances.sessionRecording, + }); + instances.channelRecording = this.server.create('channel-recording', { + connection_recording: instances.connectionRecording, + }); + urls.globalScope = `/scopes/global`; + urls.sessionRecordings = `${urls.globalScope}/session-recordings`; + urls.sessionRecording = `${urls.sessionRecordings}/${instances.sessionRecording.id}/channels-by-connection`; + urls.channelRecording = `${urls.sessionRecording}/${instances.channelRecording.id}`; + authenticateSession({}); + featuresService = this.owner.lookup('service:features'); + }); + + test('visiting a session recording', async function (assert) { + assert.expect(1); + featuresService.enable('ssh-session-recording'); + // Visit session recordings + await visit(urls.sessionRecordings); + await a11yAudit(); + // Click a session recording and check it navigates properly + await click(LIST_SESSION_RECORDING_BUTTON); + await a11yAudit(); + assert.strictEqual(currentURL(), urls.sessionRecording); + }); + + test('user can navigate to a session recording with proper authorization', async function (assert) { + assert.expect(3); + featuresService.enable('ssh-session-recording'); + // Visit session recordings + await visit(urls.sessionRecordings); + assert.true(instances.sessionRecording.authorized_actions.includes('read')); + assert.dom(`[href="${urls.sessionRecording}"]`).exists(); + // Click a session recording and check it navigates properly + await click(LIST_SESSION_RECORDING_BUTTON); + assert.strictEqual(currentURL(), urls.sessionRecording); + }); + + test('user cannot navigate to a session recording without the read action', async function (assert) { + assert.expect(1); + featuresService.enable('ssh-session-recording'); + instances.sessionRecording.authorized_actions = + instances.sessionRecording.authorized_actions.filter( + (item) => item !== 'read' + ); + // Visit session recordings + await visit(urls.sessionRecordings); + assert.dom(LIST_SESSION_RECORDING_BUTTON).doesNotExist(); + }); + + test('user can navigate to a channel recording', async function (assert) { + assert.expect(2); + featuresService.enable('ssh-session-recording'); + // Visit session recording + await visit(urls.sessionRecording); + assert.dom('table').hasClass('hds-table'); + // Click a channel recording and check it navigates properly + await click(LIST_CHANNEL_RECORDING_BUTTON); + assert.strictEqual(currentURL(), urls.channelRecording); + }); +}); diff --git a/ui/admin/tests/acceptance/session-recordings/session-recording/channels-by-connection/channel-test.js b/ui/admin/tests/acceptance/session-recordings/session-recording/channels-by-connection/channel-test.js new file mode 100644 index 000000000..e6f9a7885 --- /dev/null +++ b/ui/admin/tests/acceptance/session-recordings/session-recording/channels-by-connection/channel-test.js @@ -0,0 +1,108 @@ +import { module, test } from 'qunit'; +import { visit, currentURL, click } from '@ember/test-helpers'; +import { setupApplicationTest } from 'admin/tests/helpers'; +import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; +import { authenticateSession } from 'ember-simple-auth/test-support'; + +module( + 'Acceptance | session recordings | session recording | channels by connection | channel', + function (hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + + // Instances + const instances = { + scopes: { + global: null, + org: null, + }, + sessionRecording: null, + connectionRecording: null, + channelRecording: null, + }; + + // Urls + const urls = { + globalScope: null, + sessionRecordings: null, + sessionRecording: null, + }; + + hooks.beforeEach(function () { + instances.scopes.global = this.server.create('scope', { id: 'global' }); + instances.scopes.org = this.server.create('scope', { + type: 'org', + scope: { id: 'global', type: 'global' }, + }); + instances.sessionRecording = this.server.create('session-recording', { + scope: instances.scopes.global, + }); + instances.connectionRecording = this.server.create( + 'connection-recording', + { + session_recording: instances.sessionRecording, + } + ); + instances.channelRecording = this.server.create('channel-recording', { + connection_recording: instances.connectionRecording, + }); + urls.globalScope = `/scopes/global`; + urls.sessionRecordings = `${urls.globalScope}/session-recordings`; + urls.sessionRecording = `${urls.sessionRecordings}/${instances.sessionRecording.id}/channels-by-connection`; + urls.connectionRecording = `${urls.sessionRecording}/${instances.channelRecording.id}`; + authenticateSession({}); + }); + + test('user can navigate to a channel', async function (assert) { + assert.expect(1); + // Visit channel + await visit(urls.connectionRecording); + assert.strictEqual(currentURL(), urls.connectionRecording); + }); + + test('user can view recording with proper authorization', async function (assert) { + assert.expect(1); + + // Visit channel + await visit(urls.connectionRecording); + + // if authorized player will render + assert.dom('.session-recording-player').exists(); + }); + + test('user cannot view recording without proper authorization: channel mime_types', async function (assert) { + assert.expect(1); + instances.channelRecording.mime_types = []; + + // Visit channel + await visit(urls.connectionRecording); + + // if unauthorized player will not render + assert.dom('.session-recording-player').doesNotExist(); + }); + + test('user cannot view recording without proper authorization: session recording download action', async function (assert) { + assert.expect(1); + instances.sessionRecording.authorized_actions = + instances.sessionRecording.authorized_actions.filter( + (item) => item !== 'download' + ); + + // Visit channel + await visit(urls.connectionRecording); + + // if unauthorized player will not render + assert.dom('.session-recording-player').doesNotExist(); + }); + + test('user can navigate back to session recording screen', async function (assert) { + assert.expect(1); + // Visit channel + await visit(urls.connectionRecording); + // click "Back to channels" link in player header + await click('.session-recording-player-header > a'); + + assert.strictEqual(currentURL(), urls.sessionRecording); + }); + } +); diff --git a/ui/admin/tests/acceptance/storage-buckets/create-test.js b/ui/admin/tests/acceptance/storage-buckets/create-test.js new file mode 100644 index 000000000..beb1522c2 --- /dev/null +++ b/ui/admin/tests/acceptance/storage-buckets/create-test.js @@ -0,0 +1,146 @@ +import { module, test } from 'qunit'; +import { visit, currentURL, click, fillIn } from '@ember/test-helpers'; +import { setupApplicationTest } from 'admin/tests/helpers'; +import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; +import a11yAudit from 'ember-a11y-testing/test-support/audit'; +import { Response } from 'miragejs'; +import { authenticateSession } from 'ember-simple-auth/test-support'; + +module('Acceptance | storage-buckets | create', function (hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + + let features; + let getStorageBucketCount; + + const SAVE_BTN_SELECTOR = '[type="submit"]'; + const CANCEL_BTN_SELECTOR = '.rose-form-actions [type="button"]'; + const NAME_FIELD_SELECTOR = '[name="name"]'; + const ALERT_TEXT_SELECTOR = '[role="alert"] div'; + const FIELD_ERROR_TEXT_SELECTOR = '.hds-form-error__message'; + const NAME_FIELD_TEXT = 'random string'; + const BUCKET_NAME_FIELD_SELECTOR = '[name="bucket_name"]'; + const BUCKET_PREFIX_FIELD_SELECTOR = '[name="bucket_prefix"]'; + + const instances = { + scopes: { + global: null, + org: null, + }, + }; + + const urls = { + globalScope: null, + storageBuckets: null, + newStorageBucket: null, + }; + + hooks.beforeEach(function () { + instances.scopes.global = this.server.create('scope', { id: 'global' }); + instances.scopes.org = this.server.create('scope', { + type: 'org', + scope: { id: 'global', type: 'global' }, + }); + urls.globalScope = `/scopes/global`; + urls.storageBuckets = `${urls.globalScope}/storage-buckets`; + urls.newStorageBucket = `${urls.storageBuckets}/new`; + getStorageBucketCount = () => + this.server.schema.storageBuckets.all().models.length; + + features = this.owner.lookup('service:features'); + features.enable('ssh-session-recording'); + authenticateSession({}); + }); + + test('users can create a new storage bucket with global scope', async function (assert) { + assert.expect(7); + const storageBucketCount = getStorageBucketCount(); + await visit(urls.storageBuckets); + + await click(`[href="${urls.newStorageBucket}"]`); + await fillIn(NAME_FIELD_SELECTOR, NAME_FIELD_TEXT); + await click('[value="global"]'); + + assert.dom(BUCKET_NAME_FIELD_SELECTOR).isNotDisabled(); + assert.dom(BUCKET_PREFIX_FIELD_SELECTOR).isNotDisabled(); + assert.dom(BUCKET_NAME_FIELD_SELECTOR).doesNotHaveAttribute('readOnly'); + assert.dom(BUCKET_PREFIX_FIELD_SELECTOR).doesNotHaveAttribute('readOnly'); + + await click(SAVE_BTN_SELECTOR); + const storageBucket = this.server.schema.storageBuckets.findBy({ + name: NAME_FIELD_TEXT, + }); + + assert.strictEqual(storageBucket.name, NAME_FIELD_TEXT); + assert.strictEqual(storageBucket.scopeId, 'global'); + assert.strictEqual(getStorageBucketCount(), storageBucketCount + 1); + }); + + test('users can create a new storage bucket with org scope', async function (assert) { + assert.expect(7); + const storageBucketCount = getStorageBucketCount(); + await visit(urls.storageBuckets); + + await click(`[href="${urls.newStorageBucket}"]`); + await fillIn(NAME_FIELD_SELECTOR, NAME_FIELD_TEXT); + await click(`[value="${instances.scopes.org.scope.id}"]`); + + assert.dom(BUCKET_NAME_FIELD_SELECTOR).isNotDisabled(); + assert.dom(BUCKET_PREFIX_FIELD_SELECTOR).isNotDisabled(); + assert.dom(BUCKET_NAME_FIELD_SELECTOR).doesNotHaveAttribute('readOnly'); + assert.dom(BUCKET_PREFIX_FIELD_SELECTOR).doesNotHaveAttribute('readOnly'); + + await click(SAVE_BTN_SELECTOR); + const storageBucket = this.server.schema.storageBuckets.findBy({ + name: NAME_FIELD_TEXT, + }); + + assert.strictEqual(storageBucket.name, NAME_FIELD_TEXT); + assert.strictEqual(storageBucket.scopeId, instances.scopes.org.scope.id); + assert.strictEqual(getStorageBucketCount(), storageBucketCount + 1); + }); + + test('user can cancel new storage bucket creation', async function (assert) { + assert.expect(2); + const storageBucketCount = getStorageBucketCount(); + await visit(urls.storageBuckets); + + await click(`[href="${urls.newStorageBucket}"]`); + await fillIn(NAME_FIELD_SELECTOR, NAME_FIELD_TEXT); + await click(CANCEL_BTN_SELECTOR); + + assert.strictEqual(currentURL(), urls.storageBuckets); + assert.strictEqual(getStorageBucketCount(), storageBucketCount); + }); + + test('saving a new storage bucket with invalid fields displays error messages', async function (assert) { + assert.expect(2); + this.server.post('/storage-buckets', () => { + return new Response( + 400, + {}, + { + status: 400, + code: 'invalid_argument', + message: 'The request was invalid.', + details: { + request_fields: [ + { + name: 'name', + description: 'Name is required.', + }, + ], + }, + } + ); + }); + await visit(urls.storageBuckets); + + await click(`[href="${urls.newStorageBucket}"]`); + await click(SAVE_BTN_SELECTOR); + await a11yAudit(); + + assert.dom(ALERT_TEXT_SELECTOR).hasText('The request was invalid.'); + assert.dom(FIELD_ERROR_TEXT_SELECTOR).hasText('Name is required.'); + }); +}); diff --git a/ui/admin/tests/acceptance/storage-buckets/delete-test.js b/ui/admin/tests/acceptance/storage-buckets/delete-test.js new file mode 100644 index 000000000..46339cd3e --- /dev/null +++ b/ui/admin/tests/acceptance/storage-buckets/delete-test.js @@ -0,0 +1,165 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +// TODO: Un-skip tests once delete storage bucket action is enabled. + +import { module, skip } from 'qunit'; +import { visit, click, currentURL } from '@ember/test-helpers'; +import { setupApplicationTest } from 'ember-qunit'; +import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; +import { Response } from 'miragejs'; +import { authenticateSession } from 'ember-simple-auth/test-support'; + +module('Acceptance | storage-buckets | delete', function (hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + + let intl; + let features; + let getStorageBucketCount; + + const DROPDOWN_BUTTON_SELECTOR = '.hds-dropdown-toggle-icon'; + const DELETE_DROPDOWN_SELECTOR = '.hds-dropdown-list-item [type="button"]'; + const DIALOG_DELETE_BTN_SELECTOR = '.rose-dialog .rose-button-primary'; + const DIALOG_CANCEL_BTN_SELECTOR = '.rose-dialog .rose-button-secondary'; + const DIALOG_MESSAGE_SELECTOR = '.rose-dialog-body'; + const DIALOG_TITLE_SELECTOR = '.rose-dialog-header'; + const NOTIFICATION_MSG_SELECTOR = '.rose-notification-body'; + const NOTIFICATION_MSG_TEXT = 'Deleted successfully.'; + + const instances = { + scopes: { + global: null, + org: null, + }, + storageBucket: null, + }; + + const urls = { + globalScope: null, + storageBuckets: null, + }; + + hooks.beforeEach(function () { + instances.scopes.global = this.server.create('scope', { id: 'global' }); + instances.scopes.org = this.server.create('scope', { + type: 'org', + scope: { id: 'global', type: 'global' }, + }); + instances.storageBucket = this.server.create('storage-bucket', { + scope: instances.scopes.global, + }); + urls.globalScope = `/scopes/global`; + urls.storageBuckets = `${urls.globalScope}/storage-buckets`; + getStorageBucketCount = () => + this.server.schema.storageBuckets.all().models.length; + + authenticateSession({}); + intl = this.owner.lookup('service:intl'); + features = this.owner.lookup('service:features'); + features.enable('ssh-session-recording'); + }); + + skip('user can delete a storage bucket', async function (assert) { + assert.expect(2); + const storageBucketCount = getStorageBucketCount(); + await visit(urls.globalScope); + + await click(`[href="${urls.storageBuckets}"]`); + await click(DROPDOWN_BUTTON_SELECTOR); + await click(DELETE_DROPDOWN_SELECTOR); + + assert.strictEqual(currentURL(), urls.storageBuckets); + assert.strictEqual(getStorageBucketCount(), storageBucketCount - 1); + }); + + skip('user can accept delete storage bucket via dialog', async function (assert) { + assert.expect(4); + const confirmService = this.owner.lookup('service:confirm'); + confirmService.enabled = true; + const storageBucketCount = getStorageBucketCount(); + await visit(urls.globalScope); + + await click(`[href="${urls.storageBuckets}"]`); + await click(DROPDOWN_BUTTON_SELECTOR); + await click(DELETE_DROPDOWN_SELECTOR); + + assert + .dom(DIALOG_DELETE_BTN_SELECTOR) + .hasText(intl.t('resources.storage-bucket.actions.delete')); + + await click(DIALOG_DELETE_BTN_SELECTOR); + + assert.dom(NOTIFICATION_MSG_SELECTOR).hasText(NOTIFICATION_MSG_TEXT); + assert.strictEqual(currentURL(), urls.storageBuckets); + assert.strictEqual(getStorageBucketCount(), storageBucketCount - 1); + }); + + skip('user can cancel delete storage bucket via dialog', async function (assert) { + assert.expect(4); + const confirmService = this.owner.lookup('service:confirm'); + confirmService.enabled = true; + const storageBucketCount = getStorageBucketCount(); + await visit(urls.globalScope); + + await click(`[href="${urls.storageBuckets}"]`); + await click(DROPDOWN_BUTTON_SELECTOR); + await click(DELETE_DROPDOWN_SELECTOR); + + assert + .dom(DIALOG_TITLE_SELECTOR) + .hasText( + intl.t('resources.storage-bucket.questions.delete-storage-bucket.title') + ); + assert + .dom(DIALOG_MESSAGE_SELECTOR) + .hasText( + intl.t( + 'resources.storage-bucket.questions.delete-storage-bucket.message' + ) + ); + + await click(DIALOG_CANCEL_BTN_SELECTOR); + + assert.strictEqual(currentURL(), urls.storageBuckets); + assert.strictEqual(getStorageBucketCount(), storageBucketCount); + }); + + skip('user cannot delete storage bucket without proper authorization', async function (assert) { + assert.expect(1); + await visit(urls.globalScope); + instances.storageBucket.authorized_actions = + instances.storageBucket.authorized_actions.filter( + (item) => item !== 'delete' + ); + + await click(`[href="${urls.storageBuckets}"]`); + await click(DROPDOWN_BUTTON_SELECTOR); + + assert.dom(DELETE_DROPDOWN_SELECTOR).doesNotExist(); + }); + + skip('deleting a storage bucket which errors displays error messages', async function (assert) { + assert.expect(1); + await visit(urls.globalScope); + this.server.del('/storage-buckets/:id', () => { + return new Response( + 490, + {}, + { + status: 490, + code: 'error', + message: 'Oops.', + } + ); + }); + + await click(`[href="${urls.storageBuckets}"]`); + await click(DROPDOWN_BUTTON_SELECTOR); + await click(DELETE_DROPDOWN_SELECTOR); + + assert.dom(NOTIFICATION_MSG_SELECTOR).hasText('Oops.'); + }); +}); diff --git a/ui/admin/tests/acceptance/storage-buckets/list-test.js b/ui/admin/tests/acceptance/storage-buckets/list-test.js new file mode 100644 index 000000000..9d220e24d --- /dev/null +++ b/ui/admin/tests/acceptance/storage-buckets/list-test.js @@ -0,0 +1,208 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { visit, click, currentURL } from '@ember/test-helpers'; +import { setupApplicationTest } from 'admin/tests/helpers'; +import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; +import { authenticateSession } from 'ember-simple-auth/test-support'; + +module('Acceptance | storage-buckets | list', function (hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + + let featuresService; + let intl; + + const STORAGE_BUCKET_TITLE = 'Storage Buckets'; + const MESSAGE_DESCRIPTION_SELECTOR = '.rose-message-description'; + const MESSAGE_LINK_SELECTOR = '.rose-message-body .hds-link-standalone'; + const DROPDOWN_BUTTON_SELECTOR = '.hds-dropdown-toggle-icon'; + const DROPDOWN_ITEM_SELECTOR = '.hds-dropdown-list-item a'; + + const instances = { + scopes: { + global: null, + org: null, + }, + storageBucket: null, + }; + + const urls = { + globalScope: null, + storageBuckets: null, + storageBucket: null, + }; + + hooks.beforeEach(function () { + instances.scopes.global = this.server.create('scope', { id: 'global' }); + instances.scopes.org = this.server.create('scope', { + type: 'org', + scope: { id: 'global', type: 'global' }, + }); + urls.globalScope = `/scopes/global`; + urls.storageBuckets = `${urls.globalScope}/storage-buckets`; + + intl = this.owner.lookup('service:intl'); + + authenticateSession({}); + featuresService = this.owner.lookup('service:features'); + }); + + test('users can navigate to storage-buckets with proper authorization', async function (assert) { + assert.expect(5); + featuresService.enable('ssh-session-recording'); + await visit(urls.globalScope); + + assert.true( + instances.scopes.global.authorized_collection_actions[ + 'storage-buckets' + ].includes('list') + ); + assert.true( + instances.scopes.global.authorized_collection_actions[ + 'storage-buckets' + ].includes('create') + ); + assert.dom(`[href="${urls.storageBuckets}"]`).exists(); + + // Tests that correct message is displayed when no buckets exist + await click(`[href="${urls.storageBuckets}"]`); + + assert + .dom(MESSAGE_DESCRIPTION_SELECTOR) + .hasText(intl.t('resources.storage-bucket.messages.none.description')); + assert.dom(MESSAGE_LINK_SELECTOR).exists(); + }); + + test('user cannot navigate to index without either list or create actions', async function (assert) { + assert.expect(5); + featuresService.enable('byow'); + featuresService.enable('ssh-session-recording'); + + instances.scopes.global.authorized_collection_actions['storage-buckets'] = + []; + + await visit(urls.globalScope); + + assert.false( + instances.scopes.global.authorized_collection_actions[ + 'storage-buckets' + ].includes('list') + ); + assert.false( + instances.scopes.global.authorized_collection_actions[ + 'storage-buckets' + ].includes('create') + ); + assert + .dom('[title="General"] a:nth-of-type(3)') + .doesNotIncludeText(STORAGE_BUCKET_TITLE); + + // Tests that correct message is displayed when no buckets exist + await visit(urls.storageBuckets); + + assert.dom(MESSAGE_DESCRIPTION_SELECTOR).hasText( + intl.t('descriptions.neither-list-nor-create', { + resource: STORAGE_BUCKET_TITLE, + }) + ); + assert.dom(MESSAGE_LINK_SELECTOR).doesNotExist(); + }); + + test('user can navigate to index with only create action', async function (assert) { + assert.expect(5); + featuresService.enable('ssh-session-recording'); + + instances.scopes.global.authorized_collection_actions['storage-buckets'] = + instances.scopes.global.authorized_collection_actions[ + 'storage-buckets' + ].filter((item) => item !== 'list'); + + await visit(urls.globalScope); + + assert.false( + instances.scopes.global.authorized_collection_actions[ + 'storage-buckets' + ].includes('list') + ); + assert.true( + instances.scopes.global.authorized_collection_actions[ + 'storage-buckets' + ].includes('create') + ); + assert.dom(`[href="${urls.storageBuckets}"]`).exists(); + + // Tests that correct message is displayed when no buckets exist + await click(`[href="${urls.storageBuckets}"]`); + + assert.dom(MESSAGE_DESCRIPTION_SELECTOR).hasText( + intl.t('descriptions.create-but-not-list', { + resource: STORAGE_BUCKET_TITLE, + }) + ); + assert.dom(MESSAGE_LINK_SELECTOR).exists(); + }); + + test('user can navigate to index with only list action', async function (assert) { + assert.expect(5); + featuresService.enable('ssh-session-recording'); + + instances.scopes.global.authorized_collection_actions['storage-buckets'] = + instances.scopes.global.authorized_collection_actions[ + 'storage-buckets' + ].filter((item) => item !== 'create'); + + await visit(urls.globalScope); + + assert.true( + instances.scopes.global.authorized_collection_actions[ + 'storage-buckets' + ].includes('list') + ); + assert.false( + instances.scopes.global.authorized_collection_actions[ + 'storage-buckets' + ].includes('create') + ); + assert.dom(`[href="${urls.storageBuckets}"]`).exists(); + + // Tests that correct message is displayed when no buckets exist + await click(`[href="${urls.storageBuckets}"]`); + + assert + .dom(MESSAGE_DESCRIPTION_SELECTOR) + .hasText(intl.t('resources.storage-bucket.messages.none.description')); + assert.dom(MESSAGE_LINK_SELECTOR).doesNotExist(); + }); + + test('user cannot navigate to index when feature is disabled', async function (assert) { + assert.expect(2); + featuresService.enable('byow'); + await visit(urls.globalScope); + assert.false(featuresService.isEnabled('ssh-session-recording')); + assert + .dom('[title="General"] a:nth-of-type(2)') + .doesNotIncludeText(STORAGE_BUCKET_TITLE); + }); + + test('edit action in table directs user to appropriate page', async function (assert) { + assert.expect(3); + featuresService.enable('ssh-session-recording'); + await visit(urls.globalScope); + instances.storageBucket = this.server.create('storage-bucket', { + scope: instances.scopes.global, + }); + urls.storageBucket = `${urls.storageBuckets}/${instances.storageBucket.id}`; + + await click(`[href="${urls.storageBuckets}"]`); + await click(DROPDOWN_BUTTON_SELECTOR); + + assert.dom(DROPDOWN_ITEM_SELECTOR).exists(); + assert.dom(DROPDOWN_ITEM_SELECTOR).hasText('Edit'); + await click(DROPDOWN_ITEM_SELECTOR); + assert.strictEqual(currentURL(), urls.storageBucket); + }); +}); diff --git a/ui/admin/tests/acceptance/storage-buckets/read-test.js b/ui/admin/tests/acceptance/storage-buckets/read-test.js new file mode 100644 index 000000000..bf09e6f38 --- /dev/null +++ b/ui/admin/tests/acceptance/storage-buckets/read-test.js @@ -0,0 +1,90 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { visit, currentURL, click } from '@ember/test-helpers'; +import { setupApplicationTest } from 'admin/tests/helpers'; +import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; +import a11yAudit from 'ember-a11y-testing/test-support/audit'; +import { authenticateSession } from 'ember-simple-auth/test-support'; + +module('Acceptance | storage-buckets | read', function (hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + + let features; + + const MESSAGE_SELECTOR = '.rose-message-subtitle'; + const TABLE_LINK_SELECTOR = '.hds-table__tbody tr:first-child a'; + + const instances = { + scopes: { + global: null, + org: null, + }, + storageBucket: null, + }; + + const urls = { + globalScope: null, + storageBuckets: null, + storageBucket: null, + unknownStorageBucket: null, + }; + + hooks.beforeEach(function () { + instances.scopes.global = this.server.create('scope', { id: 'global' }); + instances.scopes.org = this.server.create('scope', { + type: 'org', + scope: { id: 'global', type: 'global' }, + }); + instances.storageBucket = this.server.create('storage-bucket', { + scope: instances.scopes.global, + }); + urls.globalScope = `/scopes/global`; + urls.storageBuckets = `${urls.globalScope}/storage-buckets`; + urls.storageBucket = `${urls.storageBuckets}/${instances.storageBucket.id}`; + urls.unknownStorageBucket = `${urls.storageBuckets}/foo`; + + features = this.owner.lookup('service:features'); + features.enable('ssh-session-recording'); + authenticateSession({}); + }); + + test('visiting a storage bucket', async function (assert) { + assert.expect(1); + await visit(urls.globalScope); + await a11yAudit(); + + await click(`[href="${urls.storageBuckets}"]`); + await a11yAudit(); + await click(`[href="${urls.storageBucket}"]`); + await a11yAudit(); + + assert.strictEqual(currentURL(), urls.storageBucket); + }); + + test('cannot navigate to a storage bucket without proper authorization', async function (assert) { + assert.expect(1); + await visit(urls.globalScope); + instances.storageBucket.authorized_actions = + instances.storageBucket.authorized_actions.filter( + (item) => item !== 'read' + ); + + await click(`[href="${urls.storageBuckets}"]`); + + assert.dom(TABLE_LINK_SELECTOR).doesNotExist(); + }); + + test('visiting an unknown storage bucket displays 404 message', async function (assert) { + assert.expect(1); + + await visit(urls.unknownStorageBucket); + await a11yAudit(); + + assert.dom(MESSAGE_SELECTOR).hasText('Error 404'); + }); +}); diff --git a/ui/admin/tests/acceptance/storage-buckets/update-test.js b/ui/admin/tests/acceptance/storage-buckets/update-test.js new file mode 100644 index 000000000..aec19b55e --- /dev/null +++ b/ui/admin/tests/acceptance/storage-buckets/update-test.js @@ -0,0 +1,193 @@ +import { module, test } from 'qunit'; +import { visit, click, fillIn, findAll } from '@ember/test-helpers'; +import { setupApplicationTest } from 'ember-qunit'; +import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; +import { Response } from 'miragejs'; +import { + authenticateSession, + // These are left here intentionally for future reference. + //currentSession, + //invalidateSession, +} from 'ember-simple-auth/test-support'; + +module('Acceptance | storage-buckets | update', function (hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + + let features; + + const BUTTON_SELECTOR = '.rose-form-actions [type="button"]'; + const SAVE_BUTTON_SELECTOR = '.rose-form-actions [type="submit"]'; + const NAME_FIELD_SELECTOR = '[name="name"]'; + const REGION_FIELD_SELECTOR = '[name="region"]'; + const ALERT_TEXT_SELECTOR = '[role="alert"] div'; + const FIELD_ERROR_TEXT_SELECTOR = '.hds-form-error__message'; + const SECRET_FIELD_BUTTON_SELECTOR = '.secret-input [type="button"]'; + const ACCESS_KEY_ID_FIELD_SELECTOR = '[name="access_key_id"]'; + const SECRET_ACCESS_KEY_FIELD_SELECTOR = '[name="secret_access_key"]'; + const NAME_FIELD_TEXT = 'Updated storage-bucket name'; + const ACCESS_KEY_ID_FIELD_TEXT = 'Updated access key id'; + const SECRET_ACCESS_KEY_FIELD_TEXT = 'Update secret access key'; + const BUCKET_NAME_FIELD_SELECTOR = '[name="bucket_name"]'; + const BUCKET_PREFIX_FIELD_SELECTOR = '[name="bucket_prefix"]'; + + const instances = { + scopes: { + global: null, + }, + storageBucket: null, + }; + + const urls = { + globalScope: null, + storageBuckets: null, + storageBucket: null, + }; + + hooks.beforeEach(function () { + instances.scopes.global = this.server.create('scope', { id: 'global' }); + instances.storageBucket = this.server.create('storage-bucket', { + scope: instances.scopes.global, + }); + urls.globalScope = `/scopes/global`; + urls.storageBuckets = `${urls.globalScope}/storage-buckets`; + urls.storageBucket = `${urls.storageBuckets}/${instances.storageBucket.id}`; + + features = this.owner.lookup('service:features'); + features.enable('ssh-session-recording'); + authenticateSession({}); + }); + + test('can save changes to an existing storage-bucket', async function (assert) { + assert.expect(3); + await visit(urls.storageBuckets); + + await click(`[href="${urls.storageBucket}"]`); + await click(BUTTON_SELECTOR, 'Click edit mode'); + await fillIn(NAME_FIELD_SELECTOR, NAME_FIELD_TEXT); + await click(SAVE_BUTTON_SELECTOR, 'Click save'); + + assert.dom(`[href="${urls.storageBucket}"]`).isVisible(); + assert.dom(NAME_FIELD_SELECTOR).hasValue(NAME_FIELD_TEXT); + assert.strictEqual(instances.storageBucket.name, NAME_FIELD_TEXT); + }); + + test('can cancel changes to an existing storage-bucket', async function (assert) { + assert.expect(2); + const name = instances.storageBucket.name; + await visit(urls.storageBuckets); + + await click(`[href="${urls.storageBucket}"]`); + await click(BUTTON_SELECTOR, 'Click edit mode'); + await fillIn(NAME_FIELD_SELECTOR, NAME_FIELD_TEXT); + await click(BUTTON_SELECTOR, 'Click cancel'); + + assert.dom(NAME_FIELD_SELECTOR).hasValue(`${name}`); + assert.strictEqual(instances.storageBucket.name, name); + }); + + test('can save changes to access key fields', async function (assert) { + assert.expect(8); + await visit(urls.storageBuckets); + + await click(`[href="${urls.storageBucket}"]`); + + assert.dom(SECRET_FIELD_BUTTON_SELECTOR).exists({ count: 2 }); + assert.dom(SECRET_FIELD_BUTTON_SELECTOR).isDisabled(); + + await click(BUTTON_SELECTOR, 'Click edit mode'); + + assert.dom(SECRET_FIELD_BUTTON_SELECTOR).isEnabled(); + assert.dom(SECRET_FIELD_BUTTON_SELECTOR).hasText('Edit'); + + await click(SECRET_FIELD_BUTTON_SELECTOR); + await click(findAll(SECRET_FIELD_BUTTON_SELECTOR)[1]); + + assert.dom(SECRET_FIELD_BUTTON_SELECTOR).hasText('Cancel'); + + await fillIn(ACCESS_KEY_ID_FIELD_SELECTOR, ACCESS_KEY_ID_FIELD_TEXT); + await fillIn( + SECRET_ACCESS_KEY_FIELD_SELECTOR, + SECRET_ACCESS_KEY_FIELD_TEXT + ); + await click(SAVE_BUTTON_SELECTOR, 'Click save'); + + assert.dom(SECRET_FIELD_BUTTON_SELECTOR).isDisabled(); + assert.dom(ACCESS_KEY_ID_FIELD_SELECTOR).hasNoValue(); + assert.dom(SECRET_ACCESS_KEY_FIELD_SELECTOR).hasNoValue(); + }); + + test('can cancel changes to access key fields', async function (assert) { + assert.expect(3); + await visit(urls.storageBuckets); + + await click(`[href="${urls.storageBucket}"]`); + await click(BUTTON_SELECTOR, 'Click edit mode'); + await click(SECRET_FIELD_BUTTON_SELECTOR, 'Click edit button'); + await click(findAll(SECRET_FIELD_BUTTON_SELECTOR)[1], 'Click edit button'); + await fillIn(ACCESS_KEY_ID_FIELD_SELECTOR, ACCESS_KEY_ID_FIELD_TEXT); + await fillIn( + SECRET_ACCESS_KEY_FIELD_SELECTOR, + SECRET_ACCESS_KEY_FIELD_TEXT + ); + await click(SECRET_FIELD_BUTTON_SELECTOR, 'Click cancel button'); + await click( + findAll(SECRET_FIELD_BUTTON_SELECTOR)[1], + 'Click cancel button' + ); + await click(SAVE_BUTTON_SELECTOR, 'Click save'); + + assert.dom(SECRET_FIELD_BUTTON_SELECTOR).isDisabled(); + assert.dom(ACCESS_KEY_ID_FIELD_SELECTOR).hasNoValue(); + assert.dom(SECRET_ACCESS_KEY_FIELD_SELECTOR).hasNoValue(); + }); + + test('saving an existing storage-bucket with invalid fields will display error messages', async function (assert) { + assert.expect(2); + await visit(urls.storageBuckets); + this.server.patch('/storage-buckets/:id', () => { + return new Response( + 400, + {}, + { + status: 400, + code: 'invalid_argument', + message: 'The request was invalid.', + details: { + request_fields: [ + { + name: 'region', + description: 'Region is required.', + }, + ], + }, + } + ); + }); + + await click(`[href="${urls.storageBucket}"]`); + await click(BUTTON_SELECTOR, 'Activate edit mode'); + await fillIn(REGION_FIELD_SELECTOR, 'random string'); + await click(SAVE_BUTTON_SELECTOR); + + assert.dom(ALERT_TEXT_SELECTOR).hasText('The request was invalid.'); + assert.dom(FIELD_ERROR_TEXT_SELECTOR).hasText('Region is required.'); + }); + + test('user cannot edit bucket name and bucket prefix fields in a storage bucket form', async function (assert) { + assert.expect(6); + await visit(urls.storageBuckets); + + await click(`[href="${urls.storageBucket}"]`); + + assert.dom(BUCKET_NAME_FIELD_SELECTOR).isDisabled(); + assert.dom(BUCKET_PREFIX_FIELD_SELECTOR).isDisabled(); + + await click(BUTTON_SELECTOR, 'Click edit mode'); + + assert.dom(BUCKET_NAME_FIELD_SELECTOR).hasAttribute('readOnly'); + assert.dom(BUCKET_PREFIX_FIELD_SELECTOR).hasAttribute('readOnly'); + assert.dom(BUCKET_NAME_FIELD_SELECTOR).isNotDisabled(); + assert.dom(BUCKET_PREFIX_FIELD_SELECTOR).isNotDisabled(); + }); +}); diff --git a/ui/admin/tests/acceptance/targets/enable-session-recording-test.js b/ui/admin/tests/acceptance/targets/enable-session-recording-test.js new file mode 100644 index 000000000..12048e3bd --- /dev/null +++ b/ui/admin/tests/acceptance/targets/enable-session-recording-test.js @@ -0,0 +1,235 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { visit, find, click, currentURL } from '@ember/test-helpers'; +import { setupApplicationTest } from 'ember-qunit'; +import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; +import a11yAudit from 'ember-a11y-testing/test-support/audit'; +import { authenticateSession } from 'ember-simple-auth/test-support'; +import { TYPE_TARGET_SSH } from 'api/models/target'; +import select from '@ember/test-helpers/dom/select'; + +module('Acceptance | targets | enable session recording', function (hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + + let featuresService; + let storageBucketOne; + let storageBucketTwo; + + const SETTINGS_LINK_SELECTOR = '.target-sidebar .hds-link-standalone'; + const ENABLE_BUTTON_SELECTOR = '.target-sidebar .hds-button'; + const DROPDOWN_SELECTOR = '[name=storage_bucket_id]'; + const LINK_LIST_SELECTOR = '.link-list-item > a'; + const LINK_LIST_SELECTOR_ITEM_TEXT = '.link-list-item__text'; + const TOGGLE_SELECTOR = '[name=target-enable-session-recording]'; + const LINK_TO_NEW_STORAGE_BUCKET = + '.enable-session-recording-toggle .hds-link-standalone'; + const SAVE_BTN_SELECTOR = '.rose-form-actions .rose-button-primary'; + const CANCEL_BTN_SELECTOR = '.rose-form-actions .rose-button-secondary'; + + // Instances + const instances = { + scopes: { + global: null, + org: null, + }, + sessionRecording: null, + }; + + //URLs + const urls = { + globalScope: null, + orgScope: null, + projectScope: null, + targets: null, + target: null, + enableSessionRecording: null, + storageBuckets: null, + newStorageBucket: null, + }; + + hooks.beforeEach(function () { + featuresService = this.owner.lookup('service:features'); + // Generate resources + instances.scopes.global = this.server.create('scope', { id: 'global' }); + instances.scopes.org = this.server.create('scope', { + type: 'org', + scope: { id: 'global', type: 'global' }, + }); + instances.scopes.project = this.server.create('scope', { + type: 'project', + scope: { id: instances.scopes.org.id, type: 'org' }, + }); + + instances.target = this.server.create('target', { + scope: instances.scopes.project, + type: TYPE_TARGET_SSH, + }); + + instances.storageBucket = this.server.createList('storage-bucket', 2, { + scope: instances.scopes.global, + }); + + storageBucketOne = instances.storageBucket[0]; + storageBucketTwo = instances.storageBucket[1]; + + // Generate route URLs for resources + urls.globalScope = `/scopes/global/scopes`; + urls.orgScope = `/scopes/${instances.scopes.org.id}/scopes`; + urls.projectScope = `/scopes/${instances.scopes.project.id}`; + urls.targets = `${urls.projectScope}/targets`; + urls.target = `${urls.targets}/${instances.target.id}`; + urls.enableSessionRecording = `${urls.target}/enable-session-recording`; + urls.storageBuckets = `${urls.projectScope}/storage-buckets`; + urls.newStorageBucket = `${urls.storageBuckets}/new`; + urls.storageBucket = `${urls.storageBuckets}/${storageBucketOne.id}`; + + authenticateSession({}); + }); + + test('cannot enable session recording for a target without proper authorization', async function (assert) { + assert.expect(2); + assert.false(featuresService.isEnabled('ssh-session-recording')); + await visit(urls.target); + await a11yAudit(); + assert.dom('.target-sidebar a').doesNotExist(); + }); + + test('users can click on enable-recording button in target session-recording sidebar and it takes them to enable session recording', async function (assert) { + featuresService.enable('ssh-session-recording'); + assert.expect(2); + await visit(urls.target); + assert.dom(SETTINGS_LINK_SELECTOR).doesNotExist(); + await click(ENABLE_BUTTON_SELECTOR); + assert.strictEqual(currentURL(), urls.enableSessionRecording); + }); + + test('users can click on settings link in target session-recording sidebar and it takes them to enable session recording', async function (assert) { + featuresService.enable('ssh-session-recording'); + assert.expect(1); + instances.target.update({ + storageBucketId: storageBucketOne.id, + enableSessionRecording: true, + }); + await visit(urls.target); + await click(SETTINGS_LINK_SELECTOR); + assert.strictEqual(currentURL(), urls.enableSessionRecording); + }); + + test('users can click on associated storage bucket card on an ssh target', async function (assert) { + featuresService.enable('ssh-session-recording'); + assert.expect(1); + instances.target.update({ + storageBucketId: storageBucketOne.id, + enableSessionRecording: true, + }); + await visit(urls.target); + await click(LINK_LIST_SELECTOR); + assert.strictEqual(currentURL(), urls.storageBucket); + }); + + test('toggle should be disabled and storage buckets list should be shown when enable session recording button is clicked', async function (assert) { + featuresService.enable('ssh-session-recording'); + assert.expect(3); + await visit(urls.target); + await click(ENABLE_BUTTON_SELECTOR); + assert.strictEqual(currentURL(), urls.enableSessionRecording); + assert.dom(TOGGLE_SELECTOR).isVisible(); + assert.dom(DROPDOWN_SELECTOR).isNotVisible(); + }); + + test('storage buckets list is hidden when toggle is disabled', async function (assert) { + featuresService.enable('ssh-session-recording'); + assert.expect(4); + await visit(urls.target); + await click(ENABLE_BUTTON_SELECTOR); + assert.strictEqual(currentURL(), urls.enableSessionRecording); + assert.dom(TOGGLE_SELECTOR).isVisible(); + assert.dom(DROPDOWN_SELECTOR).isNotVisible(); + await click(TOGGLE_SELECTOR); + assert.dom(DROPDOWN_SELECTOR).isVisible(); + }); + + test('link to add new storage bucket should be displayed and redirect to new storage buckets form', async function (assert) { + featuresService.enable('ssh-session-recording'); + assert.expect(3); + await visit(urls.target); + await click(ENABLE_BUTTON_SELECTOR); + assert.strictEqual(currentURL(), urls.enableSessionRecording); + assert.dom(LINK_TO_NEW_STORAGE_BUCKET).isVisible(); + await click(LINK_TO_NEW_STORAGE_BUCKET); + assert.strictEqual(currentURL(), urls.newStorageBucket); + }); + + test('retain last selected dropdown list value when the toggle is off', async function (assert) { + featuresService.enable('ssh-session-recording'); + assert.expect(3); + instances.target.update({ + storageBucketId: storageBucketOne.id, + enable_session_recording: true, + }); + await visit(urls.target); + await click(SETTINGS_LINK_SELECTOR); + assert.strictEqual(currentURL(), urls.enableSessionRecording); + + const existingStorageBucketId = find(DROPDOWN_SELECTOR).value; + + //disable the toggle, dropdown should be hidden + await click(TOGGLE_SELECTOR); + assert.dom(DROPDOWN_SELECTOR).isNotVisible(); + + //enable the toggle now, + //it should have the previously selected storage_bucket_id + await click(TOGGLE_SELECTOR); + assert.dom(DROPDOWN_SELECTOR).hasValue(existingStorageBucketId); + }); + + test('can assign a storage bucket for the target', async function (assert) { + featuresService.enable('ssh-session-recording'); + assert.expect(5); + await visit(urls.target); + await click(ENABLE_BUTTON_SELECTOR); + + assert.strictEqual(currentURL(), urls.enableSessionRecording); + await click(TOGGLE_SELECTOR); + assert.dom(DROPDOWN_SELECTOR).hasNoValue(); + await select(DROPDOWN_SELECTOR, storageBucketTwo.id); + assert.dom(DROPDOWN_SELECTOR).hasValue(); + await click(SAVE_BTN_SELECTOR); + + assert.strictEqual(currentURL(), urls.target); + + assert.strictEqual( + find(LINK_LIST_SELECTOR_ITEM_TEXT).textContent.trim(), + storageBucketTwo.name + ); + }); + + test('can cancel changes to an existing storage bucket selection', async function (assert) { + featuresService.enable('ssh-session-recording'); + assert.expect(3); + instances.target.update({ + storageBucketId: storageBucketOne.id, + enable_session_recording: true, + }); + + await visit(urls.target); + await click(SETTINGS_LINK_SELECTOR); + assert.strictEqual(currentURL(), urls.enableSessionRecording); + + //update the storage bucket selection + await select(DROPDOWN_SELECTOR, storageBucketTwo); + + await click(CANCEL_BTN_SELECTOR); + assert.strictEqual(currentURL(), urls.target); + + assert.strictEqual( + find(LINK_LIST_SELECTOR_ITEM_TEXT).textContent.trim(), + storageBucketOne.name + ); + }); +}); diff --git a/ui/admin/tests/acceptance/targets/read-test.js b/ui/admin/tests/acceptance/targets/read-test.js index 10d792b4b..c48f89e86 100644 --- a/ui/admin/tests/acceptance/targets/read-test.js +++ b/ui/admin/tests/acceptance/targets/read-test.js @@ -60,6 +60,7 @@ module('Acceptance | targets | read', function (hooks) { type: TYPE_TARGET_TCP, scope: instances.scopes.project, }); + // Generate route URLs for resources urls.globalScope = `/scopes/global/scopes`; urls.orgScope = `/scopes/${instances.scopes.org.id}/scopes`; diff --git a/ui/admin/tests/e2e/helpers/boundary-ui.js b/ui/admin/tests/e2e/helpers/boundary-ui.js index 5a2ea9ba2..deba9aaf0 100644 --- a/ui/admin/tests/e2e/helpers/boundary-ui.js +++ b/ui/admin/tests/e2e/helpers/boundary-ui.js @@ -256,7 +256,11 @@ exports.waitForSessionToBeVisible = async (page, targetName) => { * @param {string} targetName Name of the target associated with the session * @param {string} credentialName Name of the credentials to be added to the target */ -exports.addBrokeredCredentialsToTarget = async (page, targetName, credentialName) => { +exports.addBrokeredCredentialsToTarget = async ( + page, + targetName, + credentialName +) => { await page .getByRole('navigation', { name: 'Resources' }) .getByRole('link', { name: 'Targets' }) @@ -282,4 +286,4 @@ exports.addBrokeredCredentialsToTarget = async (page, targetName, credentialName ).toBeVisible(); await page.getByRole('button', { name: 'Dismiss' }).click(); await expect(page.getByRole('link', { name: credentialName })).toBeVisible(); -} +}; diff --git a/ui/admin/tests/integration/components/layout/sidebar-test.js b/ui/admin/tests/integration/components/layout/sidebar-test.js new file mode 100644 index 000000000..95a93d6d5 --- /dev/null +++ b/ui/admin/tests/integration/components/layout/sidebar-test.js @@ -0,0 +1,23 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'admin/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | layout/sidebar', function (hooks) { + setupRenderingTest(hooks); + + test('it renders', async function (assert) { + await render(hbs` + + Body content + Sidebar content + + `); + + assert.dom('.layout-sidebar').isVisible(); + assert.dom('.layout-sidebar-body').isVisible(); + assert.dom('.layout-sidebar-body').hasText('Body content'); + assert.dom('.layout-sidebar-sidebar').isVisible(); + assert.dom('.layout-sidebar-sidebar').hasText('Sidebar content'); + }); +}); diff --git a/ui/admin/tests/integration/components/layout/sidebar/body-test.js b/ui/admin/tests/integration/components/layout/sidebar/body-test.js new file mode 100644 index 000000000..837a29e59 --- /dev/null +++ b/ui/admin/tests/integration/components/layout/sidebar/body-test.js @@ -0,0 +1,18 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'admin/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | layout/sidebar/body', function (hooks) { + setupRenderingTest(hooks); + + test('it renders', async function (assert) { + await render(hbs` + + template block text + + `); + + assert.dom(this.element).hasText('template block text'); + }); +}); diff --git a/ui/admin/tests/integration/components/layout/sidebar/sidebar-test.js b/ui/admin/tests/integration/components/layout/sidebar/sidebar-test.js new file mode 100644 index 000000000..ec42c3488 --- /dev/null +++ b/ui/admin/tests/integration/components/layout/sidebar/sidebar-test.js @@ -0,0 +1,18 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'admin/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | layout/sidebar/sidebar', function (hooks) { + setupRenderingTest(hooks); + + test('it renders', async function (assert) { + await render(hbs` + + template block text + + `); + + assert.dom(this.element).hasText('template block text'); + }); +}); diff --git a/ui/admin/tests/integration/components/link-list-panel/index-test.js b/ui/admin/tests/integration/components/link-list-panel/index-test.js new file mode 100644 index 000000000..14a47344a --- /dev/null +++ b/ui/admin/tests/integration/components/link-list-panel/index-test.js @@ -0,0 +1,132 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'admin/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | link-list-panel/index', function (hooks) { + setupRenderingTest(hooks); + + test('it renders with icons, text and yield content wrapped in a link, because @route is present', async function (assert) { + assert.expect(6); + await render(hbs` + + + + + + `); + + assert.dom('[data-test-icon="cloud-upload"]').isVisible(); + assert.dom('[data-test-icon="arrow-right"]').isVisible(); + assert.dom('[data-test-icon="aws-color"]').isVisible(); + assert.dom('.hds-badge__text').hasText('AWS S3'); + assert.dom('.link-list-item__text').hasText('Storage Bucket'); + assert.dom('li.link-list-item > a').exists(); + }); + + test('it renders with icons, text and yield content WITHOUT a link, because @route is not present.', async function (assert) { + assert.expect(6); + await render(hbs` + + + + + + `); + + assert.dom('[data-test-icon="clock"]').isVisible(); + assert.dom('[data-test-icon="arrow-right"]').isNotVisible(); + assert.dom('[data-test-icon="aws-color"]').isVisible(); + assert.dom('.hds-badge__text').hasText('AWS S3'); + assert.dom('.link-list-item__text').hasText('Storage Bucket'); + assert.dom('li.link-list-item > a').doesNotExist(); + }); + + test('it renders link correctly supplying a single model', async function (assert) { + assert.expect(2); + + // ScopeId global + this.modelA = { scopeId: 'global' }; + await render(hbs` + + + + `); + assert + .dom('li.link-list-item > a') + .hasAttribute('href', `/scopes/${this.modelA.scopeId}/users`); + + // ScopeId org + this.modelB = { scopeId: 's_id6ozx3wue' }; + await render(hbs` + + + + `); + assert + .dom('li.link-list-item > a') + .hasAttribute('href', `/scopes/${this.modelB.scopeId}/users`); + }); + + test('it renders a link correctly supplying multiple models', async function (assert) { + assert.expect(2); + // ScopeId global + this.modelA = { scopeId: 'global', id: 'u_umasjqde46' }; + await render(hbs` + + + + `); + assert + .dom('li.link-list-item > a') + .hasAttribute( + 'href', + `/scopes/${this.modelA.scopeId}/users/${this.modelA.id}` + ); + + // ScopeId org + this.modelB = { scopeId: 's_id6ozx3wue', id: 'u_umasjqde46' }; + await render(hbs` + + + + `); + assert + .dom('li.link-list-item > a') + .hasAttribute( + 'href', + `/scopes/${this.modelB.scopeId}/users/${this.modelB.id}` + ); + }); + + test('it renders multiple links correctly supplying a combination of single and multiple models', async function (assert) { + this.modelA = { scopeId: 's_id6ozx3wue' }; + this.modelB = { scopeId: 's_id6ozx3wue', id: 'u_umasjqde46' }; + this.modelC = { scopeId: 'global' }; + await render(hbs` + + + + + + `); + assert + .dom('li.link-list-item:nth-child(1) > a') + .hasAttribute('href', `/scopes/${this.modelA.scopeId}/users`); + assert + .dom('li.link-list-item:nth-child(2) > a') + .hasAttribute( + 'href', + `/scopes/${this.modelB.scopeId}/users/${this.modelB.id}` + ); + assert + .dom('li.link-list-item:nth-child(3) > a') + .hasAttribute('href', `/scopes/${this.modelC.scopeId}/users`); + }); +}); diff --git a/ui/admin/tests/integration/components/metadata-list/index-test.js b/ui/admin/tests/integration/components/metadata-list/index-test.js new file mode 100644 index 000000000..90b08a598 --- /dev/null +++ b/ui/admin/tests/integration/components/metadata-list/index-test.js @@ -0,0 +1,61 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'admin/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | metadata-list/item', function (hooks) { + setupRenderingTest(hooks); + + test('it renders horizontal (default) with icon, color and text', async function (assert) { + assert.expect(3); + await render(hbs` + + + Sample text + + + `); + + assert.dom('[data-test-icon="arrow-up"]').isVisible(); + assert.dom('li.metadata-list-item').hasClass('hds-foreground-success'); + assert.dom('li.metadata-list-item').hasText('Sample text'); + }); + + test('it renders vertical with icon, color and text', async function (assert) { + assert.expect(4); + await render(hbs` + + + Sample text 2 + + + `); + + assert.dom('ul.metadata-list').hasClass('vertical'); + assert.dom('[data-test-icon="arrow-down"]').isVisible(); + assert.dom('li.metadata-list-item').hasClass('hds-foreground-action'); + assert.dom('li.metadata-list-item').hasText('Sample text 2'); + }); + + test('it renders no icon if @icon not provided', async function (assert) { + assert.expect(2); + await render(hbs` + + + Sample text 3 + + + `); + + assert.dom('.flight-icon').isNotVisible(); + assert.dom('li.metadata-list-item').hasText('Sample text 3'); + }); +}); diff --git a/ui/admin/tests/integration/components/session-recording/player-test.js b/ui/admin/tests/integration/components/session-recording/player-test.js new file mode 100644 index 000000000..0a8415298 --- /dev/null +++ b/ui/admin/tests/integration/components/session-recording/player-test.js @@ -0,0 +1,37 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'admin/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { setupIntl } from 'ember-intl/test-support'; + +module('Integration | Component | session-recording/player', function (hooks) { + setupRenderingTest(hooks); + setupIntl(hooks); + + test('it renders', async function (assert) { + const castConfig = { + version: 2, + width: 124, + height: 36, + timestamp: 1670352594, + env: { SHELL: '/bin/zsh', TERM: 'xterm-256color' }, + }; + this.cast = `${JSON.stringify(castConfig)} +[2.676444, "o", "y"] +[2.788653, "o", "a"] +[2.868652, "o", "r"] +[3.001764, "o", "n"] +[3.167636, "o", " "] +[3.313668, "o", "t"] +[3.460527, "o", "e"] +[3.561983, "o", "s"] +[3.63678, "o", "t"]`; + + await render(hbs` + + `); + + assert.dom('.session-recording-player-header').hasText('Back to channels'); + assert.dom('.ap-control-bar .ap-timer').hasText('--:----:--'); + }); +}); diff --git a/ui/admin/tests/integration/components/session-recording/status-test.js b/ui/admin/tests/integration/components/session-recording/status-test.js new file mode 100644 index 000000000..01c77093a --- /dev/null +++ b/ui/admin/tests/integration/components/session-recording/status-test.js @@ -0,0 +1,73 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'admin/tests/helpers'; +import { setupIntl } from 'ember-intl/test-support'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { + STATE_SESSION_RECORDING_STARTED, + STATE_SESSION_RECORDING_AVAILABLE, + STATE_SESSION_RECORDING_UNKNOWN, +} from 'api/models/session-recording'; + +module('Integration | Component | session-recording/status', function (hooks) { + setupRenderingTest(hooks); + setupIntl(hooks); + + const BADGE_ICON_SELECTOR = '.hds-badge__icon svg'; + const BADGE_TEXT_SELECTOR = '.hds-badge__text'; + + test('it renders a completed status badge', async function (assert) { + assert.expect(2); + this.status = STATE_SESSION_RECORDING_AVAILABLE; + + await render(hbs` + + `); + + assert + .dom(BADGE_ICON_SELECTOR) + .hasAttribute('data-test-icon', 'check-circle'); + assert.dom(BADGE_TEXT_SELECTOR).hasText('Completed'); + }); + + test('it renders an in progress status badge', async function (assert) { + assert.expect(2); + this.status = STATE_SESSION_RECORDING_STARTED; + + await render(hbs` + + `); + + assert + .dom(BADGE_ICON_SELECTOR) + .hasAttribute('data-test-icon', 'circle-dot'); + assert.dom(BADGE_TEXT_SELECTOR).hasText('Recording'); + }); + + test('it renders a failed status badge', async function (assert) { + assert.expect(2); + this.status = STATE_SESSION_RECORDING_UNKNOWN; + + await render(hbs` + + `); + + assert + .dom(BADGE_ICON_SELECTOR) + .hasAttribute('data-test-icon', 'alert-triangle'); + assert.dom(BADGE_TEXT_SELECTOR).hasText('Failed'); + }); + + test('it renders a failed status badge when the status does not match any known states', async function (assert) { + assert.expect(2); + + await render(hbs` + + `); + + assert + .dom(BADGE_ICON_SELECTOR) + .hasAttribute('data-test-icon', 'alert-triangle'); + assert.dom(BADGE_TEXT_SELECTOR).hasText('Failed'); + }); +}); diff --git a/ui/admin/tests/integration/helpers/can-test.js b/ui/admin/tests/integration/helpers/can-test.js new file mode 100644 index 000000000..3da065d55 --- /dev/null +++ b/ui/admin/tests/integration/helpers/can-test.js @@ -0,0 +1,42 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'admin/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +/** + * The can helper override in admin is exported only in a development + * environment, never in tests or production. This is a simple smoke test + * to ensure that it still functions as intended. + */ +module('Integration | Helper | can', function (hooks) { + setupRenderingTest(hooks); + + test('it renders', async function (assert) { + assert.expect(2); + + const model = { + authorized_actions: ['read'], + authorized_collection_actions: { + resource: ['list'], + }, + }; + + this.set('model', model); + + await render(hbs` + {{#if (can 'read model' this.model)}} + Read + {{/if}} + `); + + assert.dom(this.element).hasText('Read'); + + await render(hbs` + {{#if (can 'list model' this.model collection='resource')}} + List + {{/if}} + `); + + assert.dom(this.element).hasText('List'); + }); +}); diff --git a/ui/admin/tests/unit/abilities/session-recording-test.js b/ui/admin/tests/unit/abilities/session-recording-test.js new file mode 100644 index 000000000..226ffb651 --- /dev/null +++ b/ui/admin/tests/unit/abilities/session-recording-test.js @@ -0,0 +1,186 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import { TYPE_SESSION_RECORDING_SSH } from 'api/models/session-recording'; + +module('Unit | Abilities | session-recording', function (hooks) { + setupTest(hooks); + + let features; + let canService; + let store; + + hooks.beforeEach(function () { + features = this.owner.lookup('service:features'); + canService = this.owner.lookup('service:can'); + store = this.owner.lookup('service:store'); + }); + + test('can read session recording feature is enabled', function (assert) { + assert.expect(2); + + features.enable('ssh-session-recording'); + + const recordingWithAuthorizedAction = store.createRecord( + 'session-recording', + { + authorized_actions: ['read'], + type: TYPE_SESSION_RECORDING_SSH, + } + ); + const recordingWithoutAuthorizedAction = store.createRecord( + 'session-recording', + { + authorized_actions: [], + type: TYPE_SESSION_RECORDING_SSH, + } + ); + + assert.true( + canService.can('read session-recording', recordingWithAuthorizedAction) + ); + assert.false( + canService.can('read session-recording', recordingWithoutAuthorizedAction) + ); + }); + + test('cannot read session recording when feature is disabled', function (assert) { + assert.expect(2); + + const recordingWithAuthorizedAction = store.createRecord( + 'session-recording', + { + authorized_actions: ['read'], + type: TYPE_SESSION_RECORDING_SSH, + } + ); + const recordingWithoutAuthorizedAction = store.createRecord( + 'session-recording', + { + authorized_actions: [], + type: TYPE_SESSION_RECORDING_SSH, + } + ); + + assert.false( + canService.can('read session-recording', recordingWithAuthorizedAction) + ); + assert.false( + canService.can('read session-recording', recordingWithoutAuthorizedAction) + ); + }); + + test('can list session recording when in global scope', function (assert) { + assert.expect(2); + + features.enable('ssh-session-recording'); + + const scopeModelWithAuthorizedAction = store.createRecord('scope', { + authorized_collection_actions: { ['session-recordings']: ['list'] }, + type: 'global', + id: 'first', + }); + const scopeModelWithoutAuthorizedAction = store.createRecord('scope', { + authorized_collection_actions: { ['session-recordings']: [] }, + type: 'global', + id: 'second', + }); + + assert.true( + canService.can('list scope', scopeModelWithAuthorizedAction, { + collection: 'session-recordings', + }) + ); + assert.false( + canService.can('list scope', scopeModelWithoutAuthorizedAction, { + collection: 'session-recordings', + }) + ); + }); + + test('can list session recording when in org scope when authorized', function (assert) { + assert.expect(2); + + features.enable('ssh-session-recording'); + + const scopeModelWithAuthorizedAction = store.createRecord('scope', { + authorized_collection_actions: { ['session-recordings']: ['list'] }, + type: 'org', + id: 'first', + }); + const scopeModelWithoutAuthorizedAction = store.createRecord('scope', { + authorized_collection_actions: { ['session-recordings']: [] }, + type: 'org', + id: 'second', + }); + + assert.true( + canService.can('list scope', scopeModelWithAuthorizedAction, { + collection: 'session-recordings', + }) + ); + assert.false( + canService.can('list scope', scopeModelWithoutAuthorizedAction, { + collection: 'session-recordings', + }) + ); + }); + + test('can navigate to session recording when feature is enabled', function (assert) { + assert.expect(2); + + features.enable('ssh-session-recording'); + + const scopeModelWithAuthorizedAction = store.createRecord('scope', { + authorized_collection_actions: { ['session-recordings']: ['list'] }, + type: 'global', + id: 'first', + }); + const scopeModelWithoutAuthorizedAction = store.createRecord('scope', { + authorized_collection_actions: { ['session-recordings']: [] }, + type: 'global', + id: 'second', + }); + + assert.true( + canService.can('navigate scope', scopeModelWithAuthorizedAction, { + collection: 'session-recordings', + }) + ); + assert.false( + canService.can('navigate scope', scopeModelWithoutAuthorizedAction, { + collection: 'session-recordings', + }) + ); + }); + + test('cannot navigate to session recording when feature is disabled', function (assert) { + assert.expect(2); + + const scopeModelWithAuthorizedAction = store.createRecord('scope', { + authorized_collection_actions: { ['session-recordings']: ['list'] }, + type: 'global', + id: 'first', + }); + const scopeModelWithoutAuthorizedAction = store.createRecord('scope', { + authorized_collection_actions: { ['session-recordings']: [] }, + type: 'global', + id: 'second', + }); + + assert.false( + canService.can('navigate scope', scopeModelWithAuthorizedAction, { + collection: 'session-recordings', + }) + ); + assert.false( + canService.can('navigate scope', scopeModelWithoutAuthorizedAction, { + collection: 'session-recordings', + }) + ); + }); +}); diff --git a/ui/admin/tests/unit/abilities/storage-bucket-test.js b/ui/admin/tests/unit/abilities/storage-bucket-test.js new file mode 100644 index 000000000..43dd6c811 --- /dev/null +++ b/ui/admin/tests/unit/abilities/storage-bucket-test.js @@ -0,0 +1,237 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import { + TYPE_STORAGE_BUCKET_PLUGIN, + TYPE_STORAGE_BUCKET_PLUGIN_AWS_S3, +} from 'api/models/storage-bucket'; + +module('Unit | Abilities | storage-bucket', function (hooks) { + setupTest(hooks); + + let features; + let canService; + let store; + + hooks.beforeEach(function () { + store = this.owner.lookup('service:store'); + canService = this.owner.lookup('service:can'); + features = this.owner.lookup('service:features'); + }); + + test('can read storage bucket when authorized and feature is enabled', function (assert) { + assert.expect(1); + + features.enable('ssh-session-recording'); + + const storageBucket = store.createRecord('storage-bucket', { + authorized_actions: ['read'], + type: TYPE_STORAGE_BUCKET_PLUGIN, + compositeType: TYPE_STORAGE_BUCKET_PLUGIN_AWS_S3, + }); + + assert.true(canService.can('read storage-bucket', storageBucket)); + }); + + test('cannot read storage bucket when unauthorized and feature is enabled', function (assert) { + assert.expect(1); + + features.enable('ssh-session-recording'); + + const storageBucket = store.createRecord('storage-bucket', { + authorized_actions: [], + type: TYPE_STORAGE_BUCKET_PLUGIN, + compositeType: TYPE_STORAGE_BUCKET_PLUGIN_AWS_S3, + }); + + assert.false(canService.can('read storage-bucket', storageBucket)); + }); + + test('cannot read storage bucket when authorized and feature is disabled', function (assert) { + assert.expect(1); + + const storageBucket = store.createRecord('storage-bucket', { + authorized_actions: ['read'], + type: TYPE_STORAGE_BUCKET_PLUGIN, + compositeType: TYPE_STORAGE_BUCKET_PLUGIN_AWS_S3, + }); + + assert.false(canService.can('read storage-bucket', storageBucket)); + }); + + test('cannot read storage bucket when unauthorized and feature is disabled', function (assert) { + assert.expect(1); + + const storageBucket = store.createRecord('storage-bucket', { + authorized_actions: [], + type: TYPE_STORAGE_BUCKET_PLUGIN, + compositeType: TYPE_STORAGE_BUCKET_PLUGIN_AWS_S3, + }); + + assert.false(canService.can('read storage-bucket', storageBucket)); + }); + + test('can list storage bucket when authorized and in global scope', function (assert) { + assert.expect(1); + + features.enable('ssh-session-recording'); + + const scopeModel = store.createRecord('scope', { + authorized_collection_actions: { 'storage-buckets': ['list'] }, + type: 'global', + id: 'global', + }); + + assert.true( + canService.can('list scope', scopeModel, { + collection: 'storage-buckets', + }) + ); + }); + + test('cannot list storage bucket when unauthorized and in global scope', function (assert) { + assert.expect(1); + + features.enable('ssh-session-recording'); + + const scopeModel = store.createRecord('scope', { + authorized_collection_actions: { 'storage-buckets': [] }, + type: 'global', + id: 'global', + }); + + assert.false( + canService.can('list scope', scopeModel, { + collection: 'storage-buckets', + }) + ); + }); + + test('can list storage bucket when authorized and in org scope', function (assert) { + assert.expect(1); + + features.enable('ssh-session-recording'); + + const scopeModel = store.createRecord('scope', { + authorized_collection_actions: { 'storage-buckets': ['list'] }, + type: 'org', + id: 'o_123', + }); + + assert.true( + canService.can('list scope', scopeModel, { + collection: 'storage-buckets', + }) + ); + }); + + test('can create storage bucket when authorized and in global scope', function (assert) { + assert.expect(1); + + features.enable('ssh-session-recording'); + + const scopeModel = store.createRecord('scope', { + authorized_collection_actions: { 'storage-buckets': ['create'] }, + type: 'global', + id: 'global', + }); + + assert.true( + canService.can('create scope', scopeModel, { + collection: 'storage-buckets', + }) + ); + }); + + test('can create storage bucket when authorized and in org scope', function (assert) { + assert.expect(1); + + features.enable('ssh-session-recording'); + + const scopeModel = store.createRecord('scope', { + authorized_collection_actions: { 'storage-buckets': ['create'] }, + type: 'org', + id: 'o_123', + }); + + assert.true( + canService.can('create scope', scopeModel, { + collection: 'storage-buckets', + }) + ); + }); + + test('can create storage bucket when authorized and in project scope', function (assert) { + assert.expect(1); + + features.enable('ssh-session-recording'); + + const scopeModel = store.createRecord('scope', { + authorized_collection_actions: { 'storage-buckets': ['create'] }, + type: 'project', + id: 'p_123', + }); + + assert.true( + canService.can('create scope', scopeModel, { + collection: 'storage-buckets', + }) + ); + }); + + test('cannot create storage bucket when unauthorized', function (assert) { + assert.expect(1); + + features.enable('ssh-session-recording'); + + const scopeModel = store.createRecord('scope', { + authorized_collection_actions: { 'storage-buckets': [] }, + type: 'global', + id: 'global', + }); + + assert.false( + canService.can('create scope', scopeModel, { + collection: 'storage-buckets', + }) + ); + }); + + test('can navigate to storage bucket when authorized', function (assert) { + assert.expect(1); + + features.enable('ssh-session-recording'); + + const scopeModel = store.createRecord('scope', { + authorized_collection_actions: { 'storage-buckets': ['create'] }, + type: 'global', + id: 'global', + }); + + assert.true( + canService.can('navigate scope', scopeModel, { + collection: 'storage-buckets', + }) + ); + }); + + test('cannot navigate to storage bucket when authorized and feature is disabled', function (assert) { + assert.expect(1); + + const scopeModel = store.createRecord('scope', { + authorized_collection_actions: { 'storage-buckets': ['create'] }, + type: 'global', + id: 'global', + }); + + assert.false( + canService.can('navigate scope', scopeModel, { + collection: 'storage-buckets', + }) + ); + }); +}); diff --git a/ui/admin/tests/unit/controllers/scopes/scope/session-recordings-test.js b/ui/admin/tests/unit/controllers/scopes/scope/session-recordings-test.js new file mode 100644 index 000000000..99bb09ace --- /dev/null +++ b/ui/admin/tests/unit/controllers/scopes/scope/session-recordings-test.js @@ -0,0 +1,13 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'admin/tests/helpers'; + +module('Unit | Controller | session-recordings', function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let controller = this.owner.lookup( + 'controller:scopes/scope/session-recordings' + ); + assert.ok(controller); + }); +}); diff --git a/ui/admin/tests/unit/controllers/scopes/scope/session-recordings/index-test.js b/ui/admin/tests/unit/controllers/scopes/scope/session-recordings/index-test.js new file mode 100644 index 000000000..03ad5a50c --- /dev/null +++ b/ui/admin/tests/unit/controllers/scopes/scope/session-recordings/index-test.js @@ -0,0 +1,16 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'admin/tests/helpers'; + +module( + 'Unit | Controller | scopes/scope/session-recordings/index', + function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let controller = this.owner.lookup( + 'controller:scopes/scope/session-recordings/index' + ); + assert.ok(controller); + }); + } +); diff --git a/ui/admin/tests/unit/controllers/scopes/scope/session-recordings/session-recording-test.js b/ui/admin/tests/unit/controllers/scopes/scope/session-recordings/session-recording-test.js new file mode 100644 index 000000000..842723c89 --- /dev/null +++ b/ui/admin/tests/unit/controllers/scopes/scope/session-recordings/session-recording-test.js @@ -0,0 +1,16 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'admin/tests/helpers'; + +module( + 'Unit | Controller | session-recordings | session-recording', + function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let controller = this.owner.lookup( + 'controller:scopes/scope/session-recordings/session-recording' + ); + assert.ok(controller); + }); + } +); diff --git a/ui/admin/tests/unit/controllers/scopes/scope/session-recordings/session-recording/channels-by-connection-test.js b/ui/admin/tests/unit/controllers/scopes/scope/session-recordings/session-recording/channels-by-connection-test.js new file mode 100644 index 000000000..4e0d89aaa --- /dev/null +++ b/ui/admin/tests/unit/controllers/scopes/scope/session-recordings/session-recording/channels-by-connection-test.js @@ -0,0 +1,16 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'admin/tests/helpers'; + +module( + 'Unit | Controller | session-recordings | session-recording | channels-by-connection', + function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let controller = this.owner.lookup( + 'controller:scopes/scope/session-recordings/session-recording/channels-by-connection' + ); + assert.ok(controller); + }); + } +); diff --git a/ui/admin/tests/unit/controllers/scopes/scope/session-recordings/session-recording/channels-by-connection/channel-test.js b/ui/admin/tests/unit/controllers/scopes/scope/session-recordings/session-recording/channels-by-connection/channel-test.js new file mode 100644 index 000000000..4841bc908 --- /dev/null +++ b/ui/admin/tests/unit/controllers/scopes/scope/session-recordings/session-recording/channels-by-connection/channel-test.js @@ -0,0 +1,16 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'admin/tests/helpers'; + +module( + 'Unit | Controller | session-recordings | session-recording | channels-by-connection | channel', + function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let controller = this.owner.lookup( + 'controller:scopes/scope/session-recordings/session-recording/channels-by-connection/channel' + ); + assert.ok(controller); + }); + } +); diff --git a/ui/admin/tests/unit/controllers/scopes/scope/session-recordings/session-recording/channels-by-connection/index-test.js b/ui/admin/tests/unit/controllers/scopes/scope/session-recordings/session-recording/channels-by-connection/index-test.js new file mode 100644 index 000000000..b28e1e4e7 --- /dev/null +++ b/ui/admin/tests/unit/controllers/scopes/scope/session-recordings/session-recording/channels-by-connection/index-test.js @@ -0,0 +1,104 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'admin/tests/helpers'; +import { + STATE_SESSION_RECORDING_AVAILABLE, + STATE_SESSION_RECORDING_STARTED, + STATE_SESSION_RECORDING_UNKNOWN, +} from 'api/models/session-recording'; + +module( + 'Unit | Controller | scopes/scope/session-recordings/session-recording/channels-by-connection/index', + function (hooks) { + setupTest(hooks); + + test('isSessionInprogressWithNoConnections returns true if session has started with no connections', function (assert) { + const store = this.owner.lookup('service:store'); + const controller = this.owner.lookup( + 'controller:scopes/scope/session-recordings/session-recording/channels-by-connection/index' + ); + + const sessionRecording = store.createRecord('session-recording', { + state: STATE_SESSION_RECORDING_STARTED, + connection_recordings: [], + }); + controller.set('model', { sessionRecording }); + + assert.true(controller.isSessionInprogressWithNoConnections); + }); + + test('isSessionInprogressWithNoConnections returns false if session has started and has connections', function (assert) { + const store = this.owner.lookup('service:store'); + const controller = this.owner.lookup( + 'controller:scopes/scope/session-recordings/session-recording/channels-by-connection/index' + ); + + const sessionRecording = store.createRecord('session-recording', { + state: STATE_SESSION_RECORDING_STARTED, + connection_recordings: [store.createRecord('connection-recording')], + }); + controller.set('model', { sessionRecording }); + + assert.false(controller.isSessionInprogressWithNoConnections); + }); + + test('isSessionInprogressWithNoConnections returns false if session is complete and has connections', function (assert) { + const store = this.owner.lookup('service:store'); + const controller = this.owner.lookup( + 'controller:scopes/scope/session-recordings/session-recording/channels-by-connection/index' + ); + + const sessionRecording = store.createRecord('session-recording', { + state: STATE_SESSION_RECORDING_AVAILABLE, + connection_recordings: [store.createRecord('connection-recording')], + }); + controller.set('model', { sessionRecording }); + + assert.false(controller.isSessionInprogressWithNoConnections); + }); + + test('isSessionUnknownWithNoConnections returns true if session is unknown with no connections', function (assert) { + const store = this.owner.lookup('service:store'); + const controller = this.owner.lookup( + 'controller:scopes/scope/session-recordings/session-recording/channels-by-connection/index' + ); + + const sessionRecording = store.createRecord('session-recording', { + state: STATE_SESSION_RECORDING_UNKNOWN, + connection_recordings: [], + }); + controller.set('model', { sessionRecording }); + + assert.true(controller.isSessionUnknownWithNoConnections); + }); + + test('isSessionUnknownWithNoConnections returns false if session is unknown and has connections', function (assert) { + const store = this.owner.lookup('service:store'); + const controller = this.owner.lookup( + 'controller:scopes/scope/session-recordings/session-recording/channels-by-connection/index' + ); + + const sessionRecording = store.createRecord('session-recording', { + state: STATE_SESSION_RECORDING_UNKNOWN, + connection_recordings: [store.createRecord('connection-recording')], + }); + controller.set('model', { sessionRecording }); + + assert.false(controller.isSessionUnknownWithNoConnections); + }); + + test('isSessionUnknownWithNoConnections returns false if session is complete and has connections', function (assert) { + const store = this.owner.lookup('service:store'); + const controller = this.owner.lookup( + 'controller:scopes/scope/session-recordings/session-recording/channels-by-connection/index' + ); + + const sessionRecording = store.createRecord('session-recording', { + state: STATE_SESSION_RECORDING_AVAILABLE, + connection_recordings: [store.createRecord('connection-recording')], + }); + controller.set('model', { sessionRecording }); + + assert.false(controller.isSessionUnknownWithNoConnections); + }); + } +); diff --git a/ui/admin/tests/unit/controllers/scopes/scope/storage-buckets-test.js b/ui/admin/tests/unit/controllers/scopes/scope/storage-buckets-test.js new file mode 100644 index 000000000..fa69f7929 --- /dev/null +++ b/ui/admin/tests/unit/controllers/scopes/scope/storage-buckets-test.js @@ -0,0 +1,18 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'admin/tests/helpers'; + +module('Unit | Controller | scopes/scope/storage-buckets', function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let controller = this.owner.lookup( + 'controller:scopes/scope/storage-buckets' + ); + assert.ok(controller); + }); +}); diff --git a/ui/admin/tests/unit/controllers/scopes/scope/storage-buckets/new-test.js b/ui/admin/tests/unit/controllers/scopes/scope/storage-buckets/new-test.js new file mode 100644 index 000000000..3f12de7c5 --- /dev/null +++ b/ui/admin/tests/unit/controllers/scopes/scope/storage-buckets/new-test.js @@ -0,0 +1,16 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'admin/tests/helpers'; + +module( + 'Unit | Controller | scopes/scope/storage-buckets/new', + function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let controller = this.owner.lookup( + 'controller:scopes/scope/storage-buckets/new' + ); + assert.ok(controller); + }); + } +); diff --git a/ui/admin/tests/unit/controllers/scopes/scope/storage-buckets/storage-bucket-test.js b/ui/admin/tests/unit/controllers/scopes/scope/storage-buckets/storage-bucket-test.js new file mode 100644 index 000000000..d924847b6 --- /dev/null +++ b/ui/admin/tests/unit/controllers/scopes/scope/storage-buckets/storage-bucket-test.js @@ -0,0 +1,16 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'admin/tests/helpers'; + +module( + 'Unit | Controller | scopes/scope/storage-buckets/storage-bucket', + function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let controller = this.owner.lookup( + 'controller:scopes/scope/storage-buckets/storage-bucket' + ); + assert.ok(controller); + }); + } +); diff --git a/ui/admin/tests/unit/controllers/scopes/scope/targets/target/enable-session-recording-test.js b/ui/admin/tests/unit/controllers/scopes/scope/targets/target/enable-session-recording-test.js new file mode 100644 index 000000000..0aca32f5b --- /dev/null +++ b/ui/admin/tests/unit/controllers/scopes/scope/targets/target/enable-session-recording-test.js @@ -0,0 +1,17 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'admin/tests/helpers'; + +module( + 'Unit | Controller | scopes/scope/targets/target/enable-session-recording', + function (hooks) { + setupTest(hooks); + + // TODO: Replace this with your real tests. + test('it exists', function (assert) { + let controller = this.owner.lookup( + 'controller:scopes/scope/targets/target/enable-session-recording' + ); + assert.ok(controller); + }); + } +); diff --git a/ui/admin/tests/unit/routes/scopes/scope/session-recordings-test.js b/ui/admin/tests/unit/routes/scopes/scope/session-recordings-test.js new file mode 100644 index 000000000..83b111bb5 --- /dev/null +++ b/ui/admin/tests/unit/routes/scopes/scope/session-recordings-test.js @@ -0,0 +1,11 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'admin/tests/helpers'; + +module('Unit | Route | scopes/scope/session-recordings', function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let route = this.owner.lookup('route:scopes/scope/session-recordings'); + assert.ok(route); + }); +}); diff --git a/ui/admin/tests/unit/routes/scopes/scope/session-recordings/index-test.js b/ui/admin/tests/unit/routes/scopes/scope/session-recordings/index-test.js new file mode 100644 index 000000000..eed6a0983 --- /dev/null +++ b/ui/admin/tests/unit/routes/scopes/scope/session-recordings/index-test.js @@ -0,0 +1,16 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'admin/tests/helpers'; + +module( + 'Unit | Route | scopes/scope/session-recordings/index', + function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let route = this.owner.lookup( + 'route:scopes/scope/session-recordings/index' + ); + assert.ok(route); + }); + } +); diff --git a/ui/admin/tests/unit/routes/scopes/scope/session-recordings/session-recording-test.js b/ui/admin/tests/unit/routes/scopes/scope/session-recordings/session-recording-test.js new file mode 100644 index 000000000..1c9ccdb2b --- /dev/null +++ b/ui/admin/tests/unit/routes/scopes/scope/session-recordings/session-recording-test.js @@ -0,0 +1,16 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'admin/tests/helpers'; + +module( + 'Unit | Route | scopes/scope/session-recordings/session-recording', + function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let route = this.owner.lookup( + 'route:scopes/scope/session-recordings/session-recording' + ); + assert.ok(route); + }); + } +); diff --git a/ui/admin/tests/unit/routes/scopes/scope/session-recordings/session-recording/channels-by-connection-test.js b/ui/admin/tests/unit/routes/scopes/scope/session-recordings/session-recording/channels-by-connection-test.js new file mode 100644 index 000000000..8b04301d1 --- /dev/null +++ b/ui/admin/tests/unit/routes/scopes/scope/session-recordings/session-recording/channels-by-connection-test.js @@ -0,0 +1,16 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'admin/tests/helpers'; + +module( + 'Unit | Route | scopes/scope/session-recordings/session-recording/channels-by-connection', + function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let route = this.owner.lookup( + 'route:scopes/scope/session-recordings/session-recording/channels-by-connection' + ); + assert.ok(route); + }); + } +); diff --git a/ui/admin/tests/unit/routes/scopes/scope/session-recordings/session-recording/channels-by-connection/channel-test.js b/ui/admin/tests/unit/routes/scopes/scope/session-recordings/session-recording/channels-by-connection/channel-test.js new file mode 100644 index 000000000..3ca0c9afb --- /dev/null +++ b/ui/admin/tests/unit/routes/scopes/scope/session-recordings/session-recording/channels-by-connection/channel-test.js @@ -0,0 +1,16 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'admin/tests/helpers'; + +module( + 'Unit | Route | scopes/scope/session-recordings/session-recording/channels-by-connection/channel', + function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let route = this.owner.lookup( + 'route:scopes/scope/session-recordings/session-recording/channels-by-connection/channel' + ); + assert.ok(route); + }); + } +); diff --git a/ui/admin/tests/unit/routes/scopes/scope/session-recordings/session-recording/channels-by-connection/channel/index-test.js b/ui/admin/tests/unit/routes/scopes/scope/session-recordings/session-recording/channels-by-connection/channel/index-test.js new file mode 100644 index 000000000..432ccb9e2 --- /dev/null +++ b/ui/admin/tests/unit/routes/scopes/scope/session-recordings/session-recording/channels-by-connection/channel/index-test.js @@ -0,0 +1,16 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'admin/tests/helpers'; + +module( + 'Unit | Route | scopes/scope/session-recordings/session-recording/channels-by-connection/channel/index', + function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let route = this.owner.lookup( + 'route:scopes/scope/session-recordings/session-recording/channels-by-connection/channel/index' + ); + assert.ok(route); + }); + } +); diff --git a/ui/admin/tests/unit/routes/scopes/scope/session-recordings/session-recording/channels-by-connection/index-test.js b/ui/admin/tests/unit/routes/scopes/scope/session-recordings/session-recording/channels-by-connection/index-test.js new file mode 100644 index 000000000..c6d4d870a --- /dev/null +++ b/ui/admin/tests/unit/routes/scopes/scope/session-recordings/session-recording/channels-by-connection/index-test.js @@ -0,0 +1,16 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'admin/tests/helpers'; + +module( + 'Unit | Route | scopes/scope/session-recordings/session-recording/channels-by-connection/index', + function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let route = this.owner.lookup( + 'route:scopes/scope/session-recordings/session-recording/channels-by-connection/index' + ); + assert.ok(route); + }); + } +); diff --git a/ui/admin/tests/unit/routes/scopes/scope/session-recordings/session-recording/index-test.js b/ui/admin/tests/unit/routes/scopes/scope/session-recordings/session-recording/index-test.js new file mode 100644 index 000000000..d57fbb527 --- /dev/null +++ b/ui/admin/tests/unit/routes/scopes/scope/session-recordings/session-recording/index-test.js @@ -0,0 +1,16 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'admin/tests/helpers'; + +module( + 'Unit | Route | scopes/scope/session-recordings/session-recording/index', + function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let route = this.owner.lookup( + 'route:scopes/scope/session-recordings/session-recording/index' + ); + assert.ok(route); + }); + } +); diff --git a/ui/admin/tests/unit/routes/scopes/scope/storage-buckets-test.js b/ui/admin/tests/unit/routes/scopes/scope/storage-buckets-test.js new file mode 100644 index 000000000..6f1795910 --- /dev/null +++ b/ui/admin/tests/unit/routes/scopes/scope/storage-buckets-test.js @@ -0,0 +1,16 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'admin/tests/helpers'; + +module('Unit | Route | scopes/scope/storage-buckets', function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let route = this.owner.lookup('route:scopes/scope/storage-buckets'); + assert.ok(route); + }); +}); diff --git a/ui/admin/tests/unit/routes/scopes/scope/storage-buckets/index-test.js b/ui/admin/tests/unit/routes/scopes/scope/storage-buckets/index-test.js new file mode 100644 index 000000000..f8978d9f9 --- /dev/null +++ b/ui/admin/tests/unit/routes/scopes/scope/storage-buckets/index-test.js @@ -0,0 +1,16 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'admin/tests/helpers'; + +module('Unit | Route | scopes/scope/storage-buckets/index', function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let route = this.owner.lookup('route:scopes/scope/storage-buckets/index'); + assert.ok(route); + }); +}); diff --git a/ui/admin/tests/unit/routes/scopes/scope/storage-buckets/new-test.js b/ui/admin/tests/unit/routes/scopes/scope/storage-buckets/new-test.js new file mode 100644 index 000000000..2c96c88cc --- /dev/null +++ b/ui/admin/tests/unit/routes/scopes/scope/storage-buckets/new-test.js @@ -0,0 +1,16 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'admin/tests/helpers'; + +module('Unit | Route | scopes/scope/storage-buckets/new', function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let route = this.owner.lookup('route:scopes/scope/storage-buckets/new'); + assert.ok(route); + }); +}); diff --git a/ui/admin/tests/unit/routes/scopes/scope/storage-buckets/storage-bucket-test.js b/ui/admin/tests/unit/routes/scopes/scope/storage-buckets/storage-bucket-test.js new file mode 100644 index 000000000..a4104dbf6 --- /dev/null +++ b/ui/admin/tests/unit/routes/scopes/scope/storage-buckets/storage-bucket-test.js @@ -0,0 +1,21 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'admin/tests/helpers'; + +module( + 'Unit | Route | scopes/scope/storage-buckets/storage-bucket', + function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let route = this.owner.lookup( + 'route:scopes/scope/storage-buckets/storage-bucket' + ); + assert.ok(route); + }); + } +); diff --git a/ui/admin/tests/unit/routes/scopes/scope/storage-buckets/storage-bucket/index-test.js b/ui/admin/tests/unit/routes/scopes/scope/storage-buckets/storage-bucket/index-test.js new file mode 100644 index 000000000..70bbcf915 --- /dev/null +++ b/ui/admin/tests/unit/routes/scopes/scope/storage-buckets/storage-bucket/index-test.js @@ -0,0 +1,21 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'admin/tests/helpers'; + +module( + 'Unit | Route | scopes/scope/storage-buckets/storage-bucket/index', + function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let route = this.owner.lookup( + 'route:scopes/scope/storage-buckets/storage-bucket/index' + ); + assert.ok(route); + }); + } +); diff --git a/ui/admin/tests/unit/routes/scopes/scope/targets/target/enable-session-recording-test.js b/ui/admin/tests/unit/routes/scopes/scope/targets/target/enable-session-recording-test.js new file mode 100644 index 000000000..434a711fa --- /dev/null +++ b/ui/admin/tests/unit/routes/scopes/scope/targets/target/enable-session-recording-test.js @@ -0,0 +1,16 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'admin/tests/helpers'; + +module( + 'Unit | Route | scopes/scope/targets/target/enable-session-recording', + function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let route = this.owner.lookup( + 'route:scopes/scope/targets/target/enable-session-recording' + ); + assert.ok(route); + }); + } +); diff --git a/yarn.lock b/yarn.lock index b966d0a4f..27ea6c46f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3295,6 +3295,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.21.0": + version "7.21.5" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.21.5.tgz#8492dddda9644ae3bda3b45eabe87382caee7200" + integrity sha512-8jI69toZqqcsnqGGqwGS4Qb1VwLOEp4hz+CXPywcvjs60u3B4Pom/U/7rm4W8tMOYEB+E9wgD0mW1l3r8qlI9Q== + dependencies: + regenerator-runtime "^0.13.11" + "@babel/template@^7.12.7", "@babel/template@^7.14.5": version "7.14.5" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.14.5.tgz#a9bc9d8b33354ff6e55a9c60d1109200a68974f4" @@ -4486,17 +4493,17 @@ resolved "https://registry.yarnpkg.com/@glimmer/env/-/env-0.1.7.tgz#fd2d2b55a9029c6b37a6c935e8c8871ae70dfa07" integrity sha1-/S0rVakCnGs3psk16MiHGucN+gc= -"@glimmer/global-context@0.83.1": - version "0.83.1" - resolved "https://registry.yarnpkg.com/@glimmer/global-context/-/global-context-0.83.1.tgz#3e2d97f10ff623bcfb5b7dc29a858d546a6c6d66" - integrity sha512-OwlgqpbOJU73EjZOZdftab0fKbtdJ4x/QQeJseL9cvaAUiK3+w52M5ONFxD1T/yPBp2Mf7NCYqA/uL8tRbzY2A== +"@glimmer/global-context@0.84.3": + version "0.84.3" + resolved "https://registry.yarnpkg.com/@glimmer/global-context/-/global-context-0.84.3.tgz#f8bf2cda9562716f2ddf3f96837e7559600635c4" + integrity sha512-8Oy9Wg5IZxMEeAnVmzD2NkObf89BeHoFSzJgJROE/deutd3rxg83mvlOez4zBBGYwnTb+VGU2LYRpet92egJjA== dependencies: "@glimmer/env" "^0.1.7" -"@glimmer/interfaces@0.83.1": - version "0.83.1" - resolved "https://registry.yarnpkg.com/@glimmer/interfaces/-/interfaces-0.83.1.tgz#fb16f5f683ddc55f130887b6141f58c0751350fe" - integrity sha512-rjAztghzX97v8I4rk3+NguM3XGYcFjc/GbJ8qrEj19KF2lUDoDBW1sB7f0tov3BD5HlrGXei/vOh4+DHfjeB5w== +"@glimmer/interfaces@0.84.3": + version "0.84.3" + resolved "https://registry.yarnpkg.com/@glimmer/interfaces/-/interfaces-0.84.3.tgz#629777a4abe373b0785656f6c8d08989f5784805" + integrity sha512-dk32ykoNojt0mvEaIW6Vli5MGTbQo58uy3Epj7ahCgTHmWOKuw/0G83f2UmFprRwFx689YTXG38I/vbpltEjzg== dependencies: "@simple-dom/interface" "^1.4.0" @@ -4526,16 +4533,16 @@ dependencies: "@glimmer/util" "^0.42.2" -"@glimmer/reference@^0.83.1": - version "0.83.1" - resolved "https://registry.yarnpkg.com/@glimmer/reference/-/reference-0.83.1.tgz#0345b95431b5bb19843b308e6311d1ef81e36192" - integrity sha512-BThEwDlMkJB1WBPWDrww+VxgGyDbwxh5FFPvGhkovvCZnCb7fAMUCt9pi6CUZtviugkWOBFtE9P4eZZbOLkXeg== +"@glimmer/reference@^0.84.3": + version "0.84.3" + resolved "https://registry.yarnpkg.com/@glimmer/reference/-/reference-0.84.3.tgz#6420ad9c102633ac83939fd1b2457269d21fb632" + integrity sha512-lV+p/aWPVC8vUjmlvYVU7WQJsLh319SdXuAWoX/SE3pq340BJlAJiEcAc6q52y9JNhT57gMwtjMX96W5Xcx/qw== dependencies: "@glimmer/env" "^0.1.7" - "@glimmer/global-context" "0.83.1" - "@glimmer/interfaces" "0.83.1" - "@glimmer/util" "0.83.1" - "@glimmer/validator" "0.83.1" + "@glimmer/global-context" "0.84.3" + "@glimmer/interfaces" "0.84.3" + "@glimmer/util" "0.84.3" + "@glimmer/validator" "0.84.3" "@glimmer/runtime@^0.42.1": version "0.42.2" @@ -4560,13 +4567,13 @@ handlebars "^4.0.13" simple-html-tokenizer "^0.5.8" -"@glimmer/syntax@^0.83.1": - version "0.83.1" - resolved "https://registry.yarnpkg.com/@glimmer/syntax/-/syntax-0.83.1.tgz#7e18dd445871c157ba0281f12a4fbf316fa49b41" - integrity sha512-n3vEd0GtjtgkOsd2gqkSimp8ecqq5KrHyana/s1XJZvVAPD5rMWT9WvAVWG8XAktns8BxjwLIUoj/vkOfA+eHg== +"@glimmer/syntax@^0.84.3": + version "0.84.3" + resolved "https://registry.yarnpkg.com/@glimmer/syntax/-/syntax-0.84.3.tgz#4045a1708cef7fd810cff42fe6deeba40c7286d0" + integrity sha512-ioVbTic6ZisLxqTgRBL2PCjYZTFIwobifCustrozRU2xGDiYvVIL0vt25h2c1ioDsX59UgVlDkIK4YTAQQSd2A== dependencies: - "@glimmer/interfaces" "0.83.1" - "@glimmer/util" "0.83.1" + "@glimmer/interfaces" "0.84.3" + "@glimmer/util" "0.84.3" "@handlebars/parser" "~2.0.0" simple-html-tokenizer "^0.5.11" @@ -4586,13 +4593,13 @@ "@glimmer/env" "^0.1.7" "@glimmer/validator" "^0.44.0" -"@glimmer/util@0.83.1": - version "0.83.1" - resolved "https://registry.yarnpkg.com/@glimmer/util/-/util-0.83.1.tgz#cc7511b03164d658cf6e3262fce5a0fcb82edceb" - integrity sha512-amvjtl9dvrkxsoitXAly9W5NUaLIE3A2J2tWhBWIL1Z6DOFotfX7ytIosOIcPhJLZCtiXPHzMutQRv0G/MSMsA== +"@glimmer/util@0.84.3": + version "0.84.3" + resolved "https://registry.yarnpkg.com/@glimmer/util/-/util-0.84.3.tgz#9ae0166982c0b48aa94b02d6ba8c2c81976ade4b" + integrity sha512-qFkh6s16ZSRuu2rfz3T4Wp0fylFj3HBsONGXQcrAdZjdUaIS6v3pNj6mecJ71qRgcym9Hbaq/7/fefIwECUiKw== dependencies: "@glimmer/env" "0.1.7" - "@glimmer/interfaces" "0.83.1" + "@glimmer/interfaces" "0.84.3" "@simple-dom/interface" "^1.4.0" "@glimmer/util@^0.42.2": @@ -4605,13 +4612,13 @@ resolved "https://registry.yarnpkg.com/@glimmer/util/-/util-0.44.0.tgz#45df98d73812440206ae7bda87cfe04aaae21ed9" integrity sha512-duAsm30uVK9jSysElCbLyU6QQYO2X9iLDLBIBUcCqck9qN1o3tK2qWiHbGK5d6g8E2AJ4H88UrfElkyaJlGrwg== -"@glimmer/validator@0.83.1", "@glimmer/validator@^0.83.0": - version "0.83.1" - resolved "https://registry.yarnpkg.com/@glimmer/validator/-/validator-0.83.1.tgz#7578cb2284f728c8e9302c51fc6e7660b570ac54" - integrity sha512-LaILSNnQgDHZpaUsfjVndbS1JfVn0xdTlJdFJblPbhoVklOBSReZVekens3EQ6xOr3BC612sRm1hBnEPixOY6A== +"@glimmer/validator@0.84.3", "@glimmer/validator@^0.84.3": + version "0.84.3" + resolved "https://registry.yarnpkg.com/@glimmer/validator/-/validator-0.84.3.tgz#cd83b7f9ab78953f23cc11a32d83d7f729c54df2" + integrity sha512-RTBV4TokUB0vI31UC7ikpV7lOYpWUlyqaKV//pRC4pexYMlmqnVhkFrdiimB/R1XyNdUOQUmnIAcdic39NkbhQ== dependencies: "@glimmer/env" "^0.1.7" - "@glimmer/global-context" "0.83.1" + "@glimmer/global-context" "0.84.3" "@glimmer/validator@^0.44.0": version "0.44.0" @@ -4706,6 +4713,15 @@ resolved "https://registry.yarnpkg.com/@hashicorp/design-system-tokens/-/design-system-tokens-1.4.1.tgz#461ee2ff7ccc46eb83098c30c20c6bba33e49cd0" integrity sha512-e5bolI2Ch1+AW2/tQrkHG6QPx4g1oONum36CfhBmX4gDChpAVoBbTG2BT+XnlBJOM5vk1uuL39ysM5QSn/BZYQ== +"@hashicorp/ember-asciinema-player@https://github.com/hashicorp/ember-asciinema-player.git#e047a096039cff70234c232efe75dcad74c6358a": + version "0.0.0" + resolved "https://github.com/hashicorp/ember-asciinema-player.git#e047a096039cff70234c232efe75dcad74c6358a" + dependencies: + asciinema-player "^3.4.0" + ember-auto-import "^2.5.0" + ember-cli-babel "^7.26.11" + ember-cli-htmlbars "^6.1.1" + "@hashicorp/ember-flight-icons@^3.0.2": version "3.0.2" resolved "https://registry.yarnpkg.com/@hashicorp/ember-flight-icons/-/ember-flight-icons-3.0.2.tgz#8d884c07842a6c88df18ca680d7883a59222a2ba" @@ -8500,6 +8516,14 @@ asar@^3.1.0: optionalDependencies: "@types/glob" "^7.1.1" +asciinema-player@^3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/asciinema-player/-/asciinema-player-3.4.0.tgz#8664e0b305ac03acd94d0ab1ff0be0e8ca4dcff8" + integrity sha512-dX6jt5S3K6daItsVWzyY9mRDK+ivC2QgqCxFkdSiNslo0vY/ZqA4upcTzqIKZqBtxppovOZk44ltg9VnHG9QVg== + dependencies: + "@babel/runtime" "^7.21.0" + solid-js "^1.3.0" + asn1.js@^5.2.0: version "5.4.1" resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-5.4.1.tgz#11a980b84ebb91781ce35b0fdc2ee294e3783f07" @@ -10087,14 +10111,14 @@ browserify-zlib@^0.2.0: pako "~1.0.5" browserslist@4.14.2, browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.14.5, browserslist@^4.16.5, browserslist@^4.16.6, browserslist@^4.16.7, browserslist@^4.17.3, browserslist@^4.17.5, browserslist@^4.19.1, browserslist@^4.20.2, browserslist@^4.21.0, browserslist@^4.21.3: - version "4.21.5" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.5.tgz#75c5dae60063ee641f977e00edd3cfb2fb7af6a7" - integrity sha512-tUkiguQGW7S3IhB7N+c2MV/HZPSCPAAiYBZXLsBhFB/PCy6ZKKsZrmBayHV9fdGV/ARIfJ14NkxKzRDjvp7L6w== + version "4.21.7" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.7.tgz#e2b420947e5fb0a58e8f4668ae6e23488127e551" + integrity sha512-BauCXrQ7I2ftSqd2mvKHGo85XR0u7Ru3C/Hxsy/0TkfCtjrmAbPdzLGasmoiBxplpDXlPvdjX9u7srIMfgasNA== dependencies: - caniuse-lite "^1.0.30001449" - electron-to-chromium "^1.4.284" - node-releases "^2.0.8" - update-browserslist-db "^1.0.10" + caniuse-lite "^1.0.30001489" + electron-to-chromium "^1.4.411" + node-releases "^2.0.12" + update-browserslist-db "^1.0.11" bser@2.1.1: version "2.1.1" @@ -10383,10 +10407,10 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001157, caniuse-lite@^1.0.30001449: - version "1.0.30001466" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001466.tgz#c1e6197c540392e09709ecaa9e3e403428c53375" - integrity sha512-ewtFBSfWjEmxUgNBSZItFSmVtvk9zkwkl1OfRZlKA8slltRN+/C/tuGVrF9styXkN36Yu3+SeJ1qkXxDEyNZ5w== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001157, caniuse-lite@^1.0.30001489: + version "1.0.30001492" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001492.tgz#4a06861788a52b4c81fd3344573b68cc87fe062b" + integrity sha512-2efF8SAZwgAX1FJr87KWhvuJxnGJKOnctQa8xLOskAXNXq8oiuqgl6u1kk3fFpsp3GgvzlRjiK1sl63hNtFADw== capture-exit@^2.0.0: version "2.0.0" @@ -11567,6 +11591,11 @@ csstype@^3.0.2: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.8.tgz#d2266a792729fb227cd216fb572f43728e1ad340" integrity sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw== +csstype@^3.1.0: + version "3.1.2" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b" + integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ== + currently-unhandled@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea" @@ -12250,10 +12279,10 @@ electron-rebuild@^3.2.6: tar "^6.0.5" yargs "^17.0.1" -electron-to-chromium@^1.4.284: - version "1.4.305" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.305.tgz#e4dfe3e06ab783f33171f9bde9e8ed092510fcd0" - integrity sha512-WETy6tG0CT5gm1O+xCbyapWNsCcmIvrn4NHViIGYo2AT8FV2qUCXdaB+WqYxSv/vS5mFqhBYnfZAAkVArjBmUg== +electron-to-chromium@^1.4.411: + version "1.4.415" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.415.tgz#0e17f870c0e8a0265ed37842cca5d877a741d5ec" + integrity sha512-3meOxxvyUOJVwa7cem6O2/MRPZ+FTzblSPSpG7biZoF9yOVrhCaS2l9C4jjW6YTm8uuEpmApuP0xQJSmUglfdg== element-resize-detector@^1.2.2: version "1.2.3" @@ -12472,7 +12501,7 @@ ember-auto-import@^2.4.0, ember-auto-import@^2.4.3: typescript-memoize "^1.0.0-alpha.3" walk-sync "^3.0.0" -ember-auto-import@^2.6.0: +ember-auto-import@^2.5.0, ember-auto-import@^2.6.0: version "2.6.3" resolved "https://registry.yarnpkg.com/ember-auto-import/-/ember-auto-import-2.6.3.tgz#f18d1b93dd10b08ba5496518436f9d56dd4e000a" integrity sha512-uLhrRDJYWCRvQ4JQ1e64XlSrqAKSd6PXaJ9ZsZI6Tlms9T4DtQFxNXasqji2ZRJBVrxEoLCRYX3RTldsQ0vNGQ== @@ -12784,7 +12813,7 @@ ember-cli-htmlbars@^6.1.0: silent-error "^1.1.1" walk-sync "^2.2.0" -ember-cli-htmlbars@^6.2.0: +ember-cli-htmlbars@^6.1.1, ember-cli-htmlbars@^6.2.0: version "6.2.0" resolved "https://registry.yarnpkg.com/ember-cli-htmlbars/-/ember-cli-htmlbars-6.2.0.tgz#18ec48ee1c93f9eed862a64eb24a9d14604f1dfc" integrity sha512-j5EGixjGau23HrqRiW/JjoAovg5UBHfjbyN7wX5ekE90knIEqUUj1z/Mo/cTx/J2VepQ2lE6HdXW9LWQ/WdMtw== @@ -13796,13 +13825,13 @@ ember-template-lint@^4.14.0: yargs "^17.5.1" ember-template-recast@^6.0.0, ember-template-recast@^6.1.3: - version "6.1.3" - resolved "https://registry.yarnpkg.com/ember-template-recast/-/ember-template-recast-6.1.3.tgz#1e9b256ee9da24bcaa7c213088d01f32afc88001" - integrity sha512-45lkfjrWlrMPlOd5rLFeQeePZwAvcS//x1x15kaiQTlqQdYWiYNXwbpWHqV+p9fXY6bEjl6EbyPhG/zBkgh8MA== + version "6.1.4" + resolved "https://registry.yarnpkg.com/ember-template-recast/-/ember-template-recast-6.1.4.tgz#e964c184adfd876878009f8aa0b84c95633fce20" + integrity sha512-fCh+rOK6z+/tsdkTbOE+e7f84P6ObnIRQrCCrnu21E4X05hPeradikIkRMhJdxn4NWrxitfZskQDd37TR/lsNQ== dependencies: - "@glimmer/reference" "^0.83.1" - "@glimmer/syntax" "^0.83.1" - "@glimmer/validator" "^0.83.0" + "@glimmer/reference" "^0.84.3" + "@glimmer/syntax" "^0.84.3" + "@glimmer/validator" "^0.84.3" async-promise-queue "^1.0.5" colors "^1.4.0" commander "^8.3.0" @@ -13810,7 +13839,7 @@ ember-template-recast@^6.0.0, ember-template-recast@^6.1.3: ora "^5.4.0" slash "^3.0.0" tmp "^0.2.1" - workerpool "^6.1.5" + workerpool "^6.4.0" ember-truth-helpers@^3.0.0: version "3.0.0" @@ -15017,6 +15046,11 @@ filesize@6.1.0: resolved "https://registry.yarnpkg.com/filesize/-/filesize-6.1.0.tgz#e81bdaa780e2451d714d71c0d7a4f3238d37ad00" integrity sha512-LpCHtPQ3sFx67z+uh2HnSyWSLLu5Jxo21795uRDuar/EOuYWXib5EmPaGIBuSnRqH2IODiKA2k5re/K9OnN/Yg== +filesize@^10.0.7: + version "10.0.7" + resolved "https://registry.yarnpkg.com/filesize/-/filesize-10.0.7.tgz#2237a816ee60a83fd0c3382ae70800e54eced3ad" + integrity sha512-iMRG7Qo9nayLoU3PNCiLizYtsy4W1ClrapeCwEgtiQelOAOuRJiw4QaLI+sSr8xr901dgHv+EYP2bCusGZgoiA== + filesize@^4.1.2: version "4.2.1" resolved "https://registry.yarnpkg.com/filesize/-/filesize-4.2.1.tgz#ab1cb2069db5d415911c1a13e144c0e743bc89bc" @@ -16705,9 +16739,9 @@ ignore@^5.2.0: integrity sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ== immer@8.0.1, immer@^9.0.6: - version "9.0.19" - resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.19.tgz#67fb97310555690b5f9cd8380d38fc0aabb6b38b" - integrity sha512-eY+Y0qcsB4TZKwgQzLaE/lqYMlKhv5J9dyd2RhhtGhNo2njPXDqU9XPfcNfa3MIDsdtZt5KlkIsirlo4dHsWdQ== + version "9.0.21" + resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.21.tgz#1e025ea31a40f24fb064f1fef23e931496330176" + integrity sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA== immutable@^4.0.0: version "4.1.0" @@ -16898,6 +16932,11 @@ intl-messageformat@10.2.1, intl-messageformat@^10.1.0: "@formatjs/icu-messageformat-parser" "2.1.10" tslib "2.4.0" +intl-unofficial-duration-unit-format@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/intl-unofficial-duration-unit-format/-/intl-unofficial-duration-unit-format-3.1.0.tgz#994feefc8d903d9e5044dc7e4e1d38ebad59394b" + integrity sha512-yjvUDwV63zSGkbeUqpDRJmOgMQoJA8OFhNeo+1KY20q+lFCtcvRWszJNsq/ssrUJRy8g+Mt3BYNfwFvRRSAkow== + invariant@^2.2.2, invariant@^2.2.3, invariant@^2.2.4: version "2.2.4" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" @@ -19740,12 +19779,10 @@ minipass@^3.1.0, minipass@^3.1.3: dependencies: yallist "^4.0.0" -minipass@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-4.0.0.tgz#7cebb0f9fa7d56f0c5b17853cbe28838a8dbbd3b" - integrity sha512-g2Uuh2jEKoht+zvO6vJqXmYpflPqzRBT+Th2h01DKh5z7wbY/AZ2gCQ78cP70YoHPyFdY30YBV5WxgLOEwOykw== - dependencies: - yallist "^4.0.0" +minipass@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-5.0.0.tgz#3e9788ffb90b694a5d0ec94479a45b5d8738133d" + integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ== minizlib@^2.0.0, minizlib@^2.1.1: version "2.1.2" @@ -19907,9 +19944,9 @@ nan@^2.12.1: integrity sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ== nanoid@^3.1.31, nanoid@^3.3.4: - version "3.3.4" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab" - integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw== + version "3.3.6" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c" + integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA== nanomatch@^1.2.9: version "1.2.13" @@ -20009,9 +20046,9 @@ node-dir@^0.1.17: minimatch "^3.0.2" node-fetch@^2.6.0, node-fetch@^2.6.1, node-fetch@^2.6.7: - version "2.6.9" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.9.tgz#7c7f744b5cc6eb5fd404e0c7a9fec630a55657e6" - integrity sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg== + version "2.6.11" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.11.tgz#cde7fc71deef3131ef80a738919f999e6edfff25" + integrity sha512-4I6pdBY1EthSqDmJkiNk3JIT8cswwR9nfeW/cPdUagJYEQG7R95WRH74wpz7ma8Gh/9dI9FP+OU+0E4FvtA55w== dependencies: whatwg-url "^5.0.0" @@ -20092,10 +20129,10 @@ node-notifier@^10.0.0, node-notifier@^8.0.1: uuid "^8.3.0" which "^2.0.2" -node-releases@^2.0.8: - version "2.0.10" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.10.tgz#c311ebae3b6a148c89b1813fd7c4d3c024ef537f" - integrity sha512-5GFldHPXVG/YZmFzJvKK2zDSzPKhEp0+ZR5SVaoSag9fsL5YgHbUHDfnG5494ISANDcK4KwPXAx2xqVEydmd7w== +node-releases@^2.0.12: + version "2.0.12" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.12.tgz#35627cc224a23bfb06fb3380f2b3afaaa7eb1039" + integrity sha512-QzsYKWhXTWx8h1kIvqfnC++o0pEmpRQA/aenALsL2F4pqNVr7YzcdMlDij5WBnwftRbJCNJL/O7zdKaxKPHqgQ== node-watch@0.7.3: version "0.7.3" @@ -22286,6 +22323,11 @@ regenerator-runtime@^0.11.0: resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg== +regenerator-runtime@^0.13.11: + version "0.13.11" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9" + integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg== + regenerator-runtime@^0.13.2, regenerator-runtime@^0.13.4, regenerator-runtime@^0.13.7: version "0.13.9" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52" @@ -23139,6 +23181,11 @@ serialize-javascript@^6.0.0: dependencies: randombytes "^2.1.0" +seroval@^0.5.0: + version "0.5.1" + resolved "https://registry.yarnpkg.com/seroval/-/seroval-0.5.1.tgz#e6d17365cdaaae7e50815c7e0bcd7102facdadf3" + integrity sha512-ZfhQVB59hmIauJG5Ydynupy8KHyr5imGNtdDhbZG68Ufh1Ynkv9KOYOAABf71oVbQxJ8VkWnMHAjEHE7fWkH5g== + serve-favicon@^2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/serve-favicon/-/serve-favicon-2.5.0.tgz#935d240cdfe0f5805307fdfe967d88942a2cbcf0" @@ -23233,9 +23280,9 @@ shebang-regex@^3.0.0: integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== shell-quote@1.7.2, shell-quote@^1.6.1, shell-quote@^1.7.3: - version "1.8.0" - resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.0.tgz#20d078d0eaf71d54f43bd2ba14a1b5b9bfa5c8ba" - integrity sha512-QHsz8GgQIGKlRi24yFc6a6lN69Idnx634w49ay6+jA5yFh7a1UY+4Rp6HPx/L/1zcEDPEij8cIsiqR6bQsE5VQ== + version "1.8.1" + resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.1.tgz#6dbf4db75515ad5bac63b4f1894c3a154c766680" + integrity sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA== shelljs@^0.8.4, shelljs@^0.8.5: version "0.8.5" @@ -23444,6 +23491,14 @@ socks@^2.6.2: ip "^1.1.5" smart-buffer "^4.2.0" +solid-js@^1.3.0: + version "1.7.4" + resolved "https://registry.yarnpkg.com/solid-js/-/solid-js-1.7.4.tgz#d2d2a2c88285956b330df59d36c3cdf09adcb465" + integrity sha512-hD/bzIpaa7DL/LGRRTLFvejQuxQaoXyH+DBgPputJW7zvFigCewQIoDvbwDR4VHTsa8VsMDPzV8BT0F9OqsS1Q== + dependencies: + csstype "^3.1.0" + seroval "^0.5.0" + sort-object-keys@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/sort-object-keys/-/sort-object-keys-1.1.3.tgz#bff833fe85cab147b34742e45863453c1e190b45" @@ -24401,13 +24456,13 @@ tapable@^2.1.1, tapable@^2.2.0: integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ== tar@^6.0.2, tar@^6.0.5, tar@^6.1.0, tar@^6.1.2, tar@^6.1.9: - version "6.1.13" - resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.13.tgz#46e22529000f612180601a6fe0680e7da508847b" - integrity sha512-jdIBIN6LTIe2jqzay/2vtYLlBHa3JF42ot3h1dW8Q0PaAG4v8rm0cvpVePtau5C6OKXGGcgO9q2AMNSWxiLqKw== + version "6.1.15" + resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.15.tgz#c9738b0b98845a3b344d334b8fa3041aaba53a69" + integrity sha512-/zKt9UyngnxIT/EAGYuxaMYgOIJiP81ab9ZfkILq4oNLPFX50qyYmu7jRj9qeXoxmJHjGlbH0+cm2uy1WCs10A== dependencies: chownr "^2.0.0" fs-minipass "^2.0.0" - minipass "^4.0.0" + minipass "^5.0.0" minizlib "^2.1.1" mkdirp "^1.0.3" yallist "^4.0.0" @@ -24516,9 +24571,9 @@ terser-webpack-plugin@^5.1.3: terser "^5.7.2" terser@^4.1.2, terser@^4.6.3, terser@^5.14.2, terser@^5.3.0, terser@^5.3.4, terser@^5.7.2: - version "5.16.6" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.16.6.tgz#f6c7a14a378ee0630fbe3ac8d1f41b4681109533" - integrity sha512-IBZ+ZQIA9sMaXmRZCUMDjNH0D5AQQfdn4WUjHL0+1lF4TP1IHRJbrhb6fNaXWikrYQTSkb7SLxkeXAiy1p7mbg== + version "5.17.6" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.17.6.tgz#d810e75e1bb3350c799cd90ebefe19c9412c12de" + integrity sha512-V8QHcs8YuyLkLHsJO5ucyff1ykrLVsR4dNnS//L5Y3NiSXpbK1J+WMVUs67eI0KTxs9JtHhgEQpXQVHlHI92DQ== dependencies: "@jridgewell/source-map" "^0.3.2" acorn "^8.5.0" @@ -25302,10 +25357,10 @@ upath@^2.0.1: resolved "https://registry.yarnpkg.com/upath/-/upath-2.0.1.tgz#50c73dea68d6f6b990f51d279ce6081665d61a8b" integrity sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w== -update-browserslist-db@^1.0.10: - version "1.0.10" - resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz#0f54b876545726f17d00cd9a2561e6dade943ff3" - integrity sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ== +update-browserslist-db@^1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz#9a2a641ad2907ae7b3616506f4b977851db5b940" + integrity sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA== dependencies: escalade "^3.1.1" picocolors "^1.0.0" @@ -26001,7 +26056,7 @@ workerpool@^6.0.0: resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.1.5.tgz#0f7cf076b6215fd7e1da903ff6f22ddd1886b581" integrity sha512-XdKkCK0Zqc6w3iTxLckiuJ81tiD/o5rBE/m+nXpRCB+/Sq4DqkfXZ/x0jW02DG1tGsfUGXbTJyZDP+eu67haSw== -workerpool@^6.1.5, workerpool@^6.2.0: +workerpool@^6.2.0: version "6.2.1" resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.1.tgz#46fc150c17d826b86a008e5a4508656777e9c343" integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw== @@ -26011,6 +26066,11 @@ workerpool@^6.2.1: resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.3.1.tgz#80a9b76e70556acfb1457a3984f8637717f7cdee" integrity sha512-0x7gJm1rhpn5SPG9NENOxPtbfUZZtK/qOg6gEdSqeDBA3dTeR91RJqSPjccPRCkhNfrnnl/dWxSSj5w9CtdzNA== +workerpool@^6.4.0: + version "6.4.0" + resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.4.0.tgz#f8d5cfb45fde32fa3b7af72ad617c3369567a462" + integrity sha512-i3KR1mQMNwY2wx20ozq2EjISGtQWDIfV56We+yGJ5yDs8jTwQiLLaqHlkBHITlCuJnYlVRmXegxFxZg7gqI++A== + wrap-ansi@^6.2.0: version "6.2.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53"