diff --git a/.github/workflows/eslint_review.yml b/.github/workflows/eslint_review.yml new file mode 100644 index 00000000000..5fc14caf77e --- /dev/null +++ b/.github/workflows/eslint_review.yml @@ -0,0 +1,30 @@ +name: ESLint Review +on: [pull_request] +jobs: + eslint: + name: ESLint Review + runs-on: ubuntu-latest + permissions: + contents: read + checks: write + strategy: + matrix: + package: [manager, api-v4, queries, shared, ui, utilities, validation] + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v2 + with: + run_install: false + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: "20.17" + cache: "pnpm" + - run: pnpm install + - uses: abailly-akamai/action-eslint@8ad68ba04fa60924ef7607b07deb5989f38f5ed6 # v1.0.2 + with: + workdir: packages/${{ matrix.package }} + github_token: ${{ secrets.GITHUB_TOKEN }} + reporter: github-pr-check + level: warning # This will report both warnings and errors + filter_mode: added # Only comment on new/modified lines \ No newline at end of file diff --git a/package.json b/package.json index 9a15c097c27..1c17edac536 100644 --- a/package.json +++ b/package.json @@ -2,16 +2,35 @@ "name": "root", "private": true, "license": "Apache-2.0", + "type": "module", "devDependencies": { + "@eslint/js": "^9.23.0", "concurrently": "9.1.0", "husky": "^9.1.6", "typescript": "^5.7.3", "vitest": "^3.0.7", "@vitest/ui": "^3.0.7", - "lint-staged": "^15.4.3" + "lint-staged": "^15.4.3", + "eslint": "^9.23.0", + "eslint-config-prettier": "^10.1.1", + "eslint-plugin-cypress": "^4.2.1", + "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-perfectionist": "^4.10.1", + "eslint-plugin-prettier": "~5.2.6", + "eslint-plugin-react": "^7.37.4", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-sonarjs": "^3.0.2", + "eslint-plugin-testing-library": "^7.1.1", + "eslint-plugin-xss": "^0.1.12", + "prettier": "~3.5.3", + "typescript-eslint": "^8.29.0", + "@typescript-eslint/eslint-plugin": "^8.29.0", + "@typescript-eslint/parser": "^8.29.0", + "@linode/eslint-plugin-cloud-manager": "^0.0.10", + "jiti": "^2.4.2" }, "scripts": { - "lint": "eslint . --quiet --ext .js,.ts,.tsx", + "lint:all": "pnpm -r --parallel lint", "install:all": "pnpm install --frozen-lockfile", "build:sdk": "pnpm run --filter @linode/api-v4 build", "build:validation": "pnpm run --filter @linode/validation build", @@ -46,7 +65,7 @@ "package-versions": "pnpm run --filter @linode/scripts package-versions", "junit:summary": "pnpm run --filter @linode/scripts --silent junit:summary", "generate-tod": "pnpm run --filter @linode/scripts --silent generate-tod", - "clean": "rm -rf node_modules && rm -rf packages/manager/node_modules && rm -rf packages/api-v4/node_modules && rm -rf packages/validation/node_modules && rm -rf packages/api-v4/lib && rm -rf packages/validation/lib && rm -rf packages/ui/node_modules && rm -rf packages/utilities/node_modules", + "clean": "concurrently \"rm -rf node_modules\" \"pnpm -r exec rm -rf node_modules lib dist\" \"pnpm store prune\"", "prepare": "husky" }, "resolutions": { diff --git a/packages/api-v4/.eslintrc.json b/packages/api-v4/.eslintrc.json deleted file mode 100644 index f551347ff3f..00000000000 --- a/packages/api-v4/.eslintrc.json +++ /dev/null @@ -1,112 +0,0 @@ -{ - "ignorePatterns": ["node_modules", "lib", "index.js", "!.eslintrc.js"], - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaVersion": 2020, - "warnOnUnsupportedTypeScriptVersion": true - }, - "plugins": ["@typescript-eslint", "sonarjs", "prettier"], - "extends": [ - "plugin:@typescript-eslint/eslint-recommended", - "plugin:@typescript-eslint/recommended", - "plugin:sonarjs/recommended", - "plugin:prettier/recommended" - ], - "rules": { - "@typescript-eslint/naming-convention": [ - "warn", - { - "format": ["camelCase", "UPPER_CASE", "PascalCase"], - "leadingUnderscore": "allow", - "selector": "variable", - "trailingUnderscore": "allow" - }, - { - "format": null, - "modifiers": ["destructured"], - "selector": "variable" - }, - { - "format": ["camelCase", "PascalCase"], - "selector": "function" - }, - { - "format": ["camelCase"], - "leadingUnderscore": "allow", - "selector": "parameter" - }, - { - "format": ["PascalCase"], - "selector": "typeLike" - } - ], - "@typescript-eslint/no-unused-vars": "off", - "@typescript-eslint/no-inferrable-types": "off", - "@typescript-eslint/no-namespace": "warn", - "@typescript-eslint/explicit-function-return-type": "off", - "@typescript-eslint/no-empty-interface": "warn", - "@typescript-eslint/no-non-null-assertion": "off", - "@typescript-eslint/no-explicit-any": "warn", - "@typescript-eslint/no-use-before-define": "off", - "@typescript-eslint/interface-name-prefix": "off", - "no-unused-vars": [ - "warn", - { - "argsIgnorePattern": "^_" - } - ], - "no-unused-expressions": "warn", - "no-bitwise": "error", - "no-caller": "error", - "no-eval": "error", - "no-throw-literal": "warn", - "no-loop-func": "error", - "no-await-in-loop": "error", - "array-callback-return": "error", - "no-invalid-this": "off", - "no-new-wrappers": "error", - "no-restricted-imports": ["error", "rxjs"], - "no-console": "error", - "no-undef-init": "off", - "radix": "error", - "sonarjs/cognitive-complexity": "warn", - "sonarjs/no-duplicate-string": "warn", - "sonarjs/prefer-immediate-return": "warn", - "sonarjs/no-identical-functions": "warn", - "sonarjs/no-redundant-jump": "warn", - "sonarjs/no-small-switch": "warn", - "no-multiple-empty-lines": "error", - "curly": "warn", - "sort-keys": "off", - "comma-dangle": "off", - "no-trailing-spaces": "warn", - "no-mixed-requires": "warn", - "spaced-comment": "warn", - "object-shorthand": "warn", - "prettier/prettier": "warn", - "@typescript-eslint/explicit-module-boundary-types": "off" - }, - "overrides": [ - { - "files": ["*ts"], - "rules": { - "@typescript-eslint/ban-types": [ - "warn", - { - "types": { - "String": true, - "Boolean": true, - "Number": true, - "Symbol": true, - "{}": false, - "Object": false, - "object": false, - "Function": false - }, - "extendDefaults": true - } - ] - } - } - ] -} diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index 8514e69fac1..2a2f3a43471 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,5 +1,26 @@ -## [2025-04-08] - v0.137.0 +## [2025-04-22] - v0.138.0 + +### Added: + +- `Linode Interfaces` to the `AccountCapability` type ([#11995](https://github.com/linode/manager/pull/11995)) + +### Changed: + +- Add VPC field to `LinodeIPsResponseIPV4` ([#11976](https://github.com/linode/manager/pull/11976)) + +### Tech Stories: +- Eslint Overhaul ([#11941](https://github.com/linode/manager/pull/11941)) + +### Upcoming Features: + +- Add schema validation for `edit alert` call in cloudpulse alerts ([#11868](https://github.com/linode/manager/pull/11868)) +- Fix the iam api for put method ([#11978](https://github.com/linode/manager/pull/11978)) +- fix the api to the right one for iam ([#11998](https://github.com/linode/manager/pull/11998)) +- Rename `DeleteLinodeConfigInterfacePayload` to `DeleteInterfaceIds` ([#12016](https://github.com/linode/manager/pull/12016)) +- fix the api to the right one for iam ([#12027](https://github.com/linode/manager/pull/12027)) + +## [2025-04-08] - v0.137.0 ### Added: @@ -27,7 +48,6 @@ ## [2025-03-25] - v0.136.0 - ### Added: - Add and update `/v4beta/nodebalancers` endpoints for NB-VPC Integration ([#11811](https://github.com/linode/manager/pull/11811)) @@ -73,7 +93,6 @@ ## [2025-02-11] - v0.134.0 - ### Added: - Labels and Taints types and params ([#11528](https://github.com/linode/manager/pull/11528)) @@ -116,7 +135,6 @@ - Add types for Quotas endpoints ([#11493](https://github.com/linode/manager/pull/11493)) - Add Notification Channel related types to cloudpulse/alerts.ts ([#11511](https://github.com/linode/manager/pull/11511)) - ## [2025-01-14] - v0.132.0 ### Added: @@ -126,7 +144,7 @@ ### Changed: -- Type of `AlertDefinitionType` to `'system'|'user'` ([#11346](https://github.com/linode/manager/pull/11346)) +- Type of `AlertDefinitionType` to `'system'|'user'` ([#11346](https://github.com/linode/manager/pull/11346)) - Property names, and types of the CreateAlertDefinitionPayload and Alert interfaces ([#11392](https://github.com/linode/manager/pull/11392)) - BaseDatabase total_disk_size_gb and used_disk_size_gb are always expected and used_disk_size_gb can be null ([#11426](https://github.com/linode/manager/pull/11426)) - Renamed `AvailableMetrics` type to `MetricDefinition` ([#11433](https://github.com/linode/manager/pull/11433)) diff --git a/packages/api-v4/eslint.config.js b/packages/api-v4/eslint.config.js new file mode 100644 index 00000000000..8818da48db2 --- /dev/null +++ b/packages/api-v4/eslint.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'eslint/config'; + +import { baseConfig } from '../manager/eslint.config.js'; + +export default defineConfig({ + extends: baseConfig, +}); diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index 839ef589f3a..38f0b84b48f 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.137.0", + "version": "0.138.0", "homepage": "https://github.com/linode/manager/tree/develop/packages/api-v4", "bugs": { "url": "https://github.com/linode/manager/issues" @@ -58,9 +58,6 @@ "devDependencies": { "axios-mock-adapter": "^1.22.0", "concurrently": "^9.0.1", - "eslint": "^6.8.0", - "eslint-plugin-sonarjs": "^0.5.0", - "prettier": "~2.2.1", "tsup": "^8.4.0" }, "lint-staged": { @@ -72,4 +69,4 @@ "tsc -p tsconfig.json --noEmit true --emitDeclarationOnly false" ] } -} +} \ No newline at end of file diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index f59f062832d..1611c429d4a 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -70,6 +70,7 @@ export const accountCapabilities = [ 'Kubernetes', 'Kubernetes Enterprise', 'Linodes', + 'Linode Interfaces', 'LKE HA Control Planes', 'LKE Network Access Control List (IP ACL)', 'Machine Images', @@ -87,7 +88,7 @@ export const accountCapabilities = [ 'VPCs', ] as const; -export type AccountCapability = typeof accountCapabilities[number]; +export type AccountCapability = (typeof accountCapabilities)[number]; export interface AccountAvailability { region: string; // will be slug of dc (matches id field of region object returned by API) @@ -101,7 +102,8 @@ export const linodeInterfaceAccountSettings = [ 'linode_only', ] as const; -export type LinodeInterfaceAccountSetting = typeof linodeInterfaceAccountSettings[number]; +export type LinodeInterfaceAccountSetting = + (typeof linodeInterfaceAccountSettings)[number]; export interface AccountSettings { managed: boolean; @@ -494,7 +496,7 @@ export const EventActionKeys = [ 'vpc_update', ] as const; -export type EventAction = typeof EventActionKeys[number]; +export type EventAction = (typeof EventActionKeys)[number]; export type EventStatus = | 'scheduled' diff --git a/packages/api-v4/src/cloudpulse/alerts.ts b/packages/api-v4/src/cloudpulse/alerts.ts index e62401bc99c..1eb2d6232fb 100644 --- a/packages/api-v4/src/cloudpulse/alerts.ts +++ b/packages/api-v4/src/cloudpulse/alerts.ts @@ -1,4 +1,7 @@ -import { createAlertDefinitionSchema } from '@linode/validation'; +import { + createAlertDefinitionSchema, + editAlertDefinitionSchema, +} from '@linode/validation'; import Request, { setURL, setMethod, @@ -54,16 +57,16 @@ export const getAlertDefinitionByServiceTypeAndId = ( export const editAlertDefinition = ( data: EditAlertDefinitionPayload, serviceType: string, - alertId: number + alertId: number, ) => Request( setURL( `${API_ROOT}/monitor/services/${encodeURIComponent( - serviceType - )}/alert-definitions/${encodeURIComponent(alertId)}` + serviceType, + )}/alert-definitions/${encodeURIComponent(alertId)}`, ), setMethod('PUT'), - setData(data) + setData(data, editAlertDefinitionSchema), ); export const getNotificationChannels = (params?: Params, filters?: Filter) => Request>( diff --git a/packages/api-v4/src/databases/types.ts b/packages/api-v4/src/databases/types.ts index 444b391a18b..c983595b964 100644 --- a/packages/api-v4/src/databases/types.ts +++ b/packages/api-v4/src/databases/types.ts @@ -45,42 +45,43 @@ export type DatabaseStatus = export type DatabaseBackupType = 'snapshot' | 'auto'; /** @deprecated TODO (UIE-8214) remove after migration */ export interface DatabaseBackup { + created: string; id: number; - type: DatabaseBackupType; label: string; - created: string; + type: DatabaseBackupType; } + export interface ConfigurationItem { description?: string; - example?: string | number | boolean; - minimum?: number; // min value for the number input + enum?: string[]; + example?: boolean | number | string; maximum?: number; // max value for the number input maxLength?: number; // max length for the text input + minimum?: number; // min value for the number input minLength?: number; // min length for the text input pattern?: string; - type?: string | [string, null] | string[]; - enum?: string[]; requires_restart?: boolean; + type?: [string, null] | string | string[]; } -export type ConfigValue = number | string | boolean; +export type ConfigValue = boolean | number | string; export type ConfigCategoryValues = Record; export type DatabaseEngineConfig = Record< string, - Record | ConfigurationItem + ConfigurationItem | Record >; export interface DatabaseInstanceAdvancedConfig { [category: string]: ConfigCategoryValues | ConfigValue; } export interface DatabaseFork { - source: number; restore_time?: string; + source: number; } export interface DatabaseCredentials { - username: string; password: string; + username: string; } interface DatabaseHosts { diff --git a/packages/api-v4/src/iam/iam.ts b/packages/api-v4/src/iam/iam.ts index 27940e4ba5a..c782242091a 100644 --- a/packages/api-v4/src/iam/iam.ts +++ b/packages/api-v4/src/iam/iam.ts @@ -1,6 +1,7 @@ import { BETA_API_ROOT } from '../constants'; import Request, { setData, setMethod, setURL } from '../request'; -import { IamUserPermissions, IamAccountPermissions } from './types'; + +import type { IamAccountPermissions, IamUserPermissions } from './types'; /** * getUserPermissions @@ -15,10 +16,10 @@ export const getUserPermissions = (username: string) => Request( setURL( `${BETA_API_ROOT}/iam/users/${encodeURIComponent( - username - )}/role-permissions` + username, + )}/role-permissions`, ), - setMethod('GET') + setMethod('GET'), ); /** * updateUserPermissions @@ -31,16 +32,16 @@ export const getUserPermissions = (username: string) => */ export const updateUserPermissions = ( username: string, - data: IamUserPermissions + data: IamUserPermissions, ) => Request( setURL( - `${BETA_API_ROOT}/iam/role-permissions/users/${encodeURIComponent( - username - )}` + `${BETA_API_ROOT}/iam/users/${encodeURIComponent( + username, + )}/role-permissions`, ), setMethod('PUT'), - setData(data) + setData(data), ); /** @@ -52,6 +53,6 @@ export const updateUserPermissions = ( export const getAccountPermissions = () => { return Request( setURL(`${BETA_API_ROOT}/iam/role-permissions`), - setMethod('GET') + setMethod('GET'), ); }; diff --git a/packages/api-v4/src/iam/types.ts b/packages/api-v4/src/iam/types.ts index 6d821d0f352..fdee06778b6 100644 --- a/packages/api-v4/src/iam/types.ts +++ b/packages/api-v4/src/iam/types.ts @@ -11,15 +11,17 @@ export type EntityTypePermissions = | 'volume' | 'vpc'; -export type AccountAccessType = +export type AccountAccessRole = | 'account_admin' | 'account_linode_admin' | 'account_viewer' + | 'account_volume_admin' | 'firewall_creator' | 'linode_contributor' | 'linode_creator'; -export type RoleType = +export type EntityAccessRole = + | 'database_admin' | 'firewall_admin' | 'firewall_creator' | 'linode_contributor' @@ -28,19 +30,19 @@ export type RoleType = | 'update_firewall'; export interface IamUserPermissions { - account_access: AccountAccessType[]; + account_access: AccountAccessRole[]; entity_access: EntityAccess[]; } export interface EntityAccess { id: number; + roles: EntityAccessRole[]; type: EntityTypePermissions; - roles: RoleType[]; } export type PermissionType = | 'acknowledge_account_agreement' - | 'add_nodebalancer_config_node' | 'add_nodebalancer_config' + | 'add_nodebalancer_config_node' | 'allocate_ip' | 'allocate_linode_ip_address' | 'assign_ips' @@ -49,18 +51,18 @@ export type PermissionType = | 'boot_linode' | 'cancel_account' | 'cancel_linode_backups' - | 'clone_linode_disk' | 'clone_linode' + | 'clone_linode_disk' | 'clone_volume' - | 'create_firewall_device' | 'create_firewall' + | 'create_firewall_device' | 'create_image' | 'create_ipv6_range' + | 'create_linode' | 'create_linode_backup_snapshot' - | 'create_linode_config_profile_interface' | 'create_linode_config_profile' + | 'create_linode_config_profile_interface' | 'create_linode_disk' - | 'create_linode' | 'create_nodebalancer' | 'create_oauth_client' | 'create_payment_method' @@ -68,24 +70,24 @@ export type PermissionType = | 'create_service_transfer' | 'create_user' | 'create_volume' - | 'create_vpc_subnet' | 'create_vpc' - | 'delete_firewall_device' + | 'create_vpc_subnet' | 'delete_firewall' + | 'delete_firewall_device' | 'delete_image' - | 'delete_linode_config_profile_interface' + | 'delete_linode' | 'delete_linode_config_profile' + | 'delete_linode_config_profile_interface' | 'delete_linode_disk' | 'delete_linode_ip_address' - | 'delete_linode' - | 'delete_nodebalancer_config_node' - | 'delete_nodebalancer_config' | 'delete_nodebalancer' + | 'delete_nodebalancer_config' + | 'delete_nodebalancer_config_node' | 'delete_payment_method' | 'delete_user' | 'delete_volume' - | 'delete_vpc_subnet' | 'delete_vpc' + | 'delete_vpc_subnet' | 'detach_volume' | 'enable_linode_backups' | 'enable_managed' @@ -136,42 +138,43 @@ export type PermissionType = | 'reorder_linode_config_profile_interfaces' | 'rescue_linode' | 'reset_linode_disk_root_password' - | 'resize_linode_disk' | 'resize_linode' + | 'resize_linode_disk' | 'resize_volume' | 'restore_linode_backup' | 'set_default_payment_method' | 'share_ips' | 'share_ipv4' | 'shutdown_linode' - | 'update_account_settings' | 'update_account' - | 'update_firewall_rules' + | 'update_account_settings' | 'update_firewall' + | 'update_firewall_rules' | 'update_image' - | 'update_linode_config_profile_interface' + | 'update_linode' | 'update_linode_config_profile' + | 'update_linode_config_profile_interface' | 'update_linode_disk' | 'update_linode_ip_address' - | 'update_linode' - | 'update_nodebalancer_config_node' - | 'update_nodebalancer_config' | 'update_nodebalancer' + | 'update_nodebalancer_config' + | 'update_nodebalancer_config_node' | 'update_user' | 'update_volume' - | 'update_vpc_subnet' | 'update_vpc' + | 'update_vpc_subnet' | 'upgrade_linode' | 'upload_image' - | 'view_account_settings' | 'view_account' - | 'view_firewall_device' + | 'view_account_settings' | 'view_firewall' + | 'view_firewall_device' | 'view_image' | 'view_invoice' + | 'view_linode' | 'view_linode_backup' - | 'view_linode_config_profile_interface' | 'view_linode_config_profile' + | 'view_linode_config_profile_interface' | 'view_linode_disk' | 'view_linode_ip_address' | 'view_linode_kernel' @@ -181,18 +184,17 @@ export type PermissionType = | 'view_linode_networking_info' | 'view_linode_stats' | 'view_linode_type' - | 'view_linode' | 'view_network_usage' - | 'view_nodebalancer_config_node' + | 'view_nodebalancer' | 'view_nodebalancer_config' + | 'view_nodebalancer_config_node' | 'view_nodebalancer_statistics' - | 'view_nodebalancer' - | 'view_payment_method' | 'view_payment' + | 'view_payment_method' | 'view_user' | 'view_volume' - | 'view_vpc_subnet' - | 'view_vpc'; + | 'view_vpc' + | 'view_vpc_subnet'; export interface IamAccountPermissions { account_access: IamAccess[]; @@ -200,13 +202,13 @@ export interface IamAccountPermissions { } export interface IamAccess { - type: EntityTypePermissions; roles: Roles[]; + type: EntityTypePermissions; } export interface Roles { - name: string; description: string; + name: string; permissions: PermissionType[]; } diff --git a/packages/api-v4/src/linodes/linode-interfaces.ts b/packages/api-v4/src/linodes/linode-interfaces.ts index 0eeb978983a..7b437296ef8 100644 --- a/packages/api-v4/src/linodes/linode-interfaces.ts +++ b/packages/api-v4/src/linodes/linode-interfaces.ts @@ -4,7 +4,7 @@ import { UpdateLinodeInterfaceSettingsSchema, UpgradeToLinodeInterfaceSchema, } from '@linode/validation'; -import type { Firewall } from 'src/firewalls/types'; + import { BETA_API_ROOT } from '../constants'; import Request, { setData, @@ -13,18 +13,20 @@ import Request, { setURL, setXFilter, } from '../request'; -import { Filter, ResourcePage as Page, Params } from '../types'; + +import type { Filter, ResourcePage as Page, Params } from '../types'; import type { CreateLinodeInterfacePayload, + LinodeInterface, LinodeInterfaceHistory, + LinodeInterfaces, LinodeInterfaceSettings, LinodeInterfaceSettingsPayload, - LinodeInterface, - LinodeInterfaces, ModifyLinodeInterfacePayload, UpgradeInterfaceData, UpgradeInterfacePayload, } from './types'; +import type { Firewall } from 'src/firewalls/types'; // These endpoints refer to the new Linode Interfaces endpoints. // For old Configuration Profile interfaces, see config.ts @@ -38,16 +40,16 @@ import type { */ export const createLinodeInterface = ( linodeId: number, - data: CreateLinodeInterfacePayload + data: CreateLinodeInterfacePayload, ) => Request( setURL( `${BETA_API_ROOT}/linode/instances/${encodeURIComponent( - linodeId - )}/interfaces` + linodeId, + )}/interfaces`, ), setMethod('POST'), - setData(data, CreateLinodeInterfaceSchema) + setData(data, CreateLinodeInterfaceSchema), ); /** @@ -61,10 +63,10 @@ export const getLinodeInterfaces = (linodeId: number) => Request( setURL( `${BETA_API_ROOT}/linode/instances/${encodeURIComponent( - linodeId - )}/interfaces` + linodeId, + )}/interfaces`, ), - setMethod('GET') + setMethod('GET'), ); /** @@ -77,17 +79,17 @@ export const getLinodeInterfaces = (linodeId: number) => export const getLinodeInterfacesHistory = ( linodeId: number, params?: Params, - filters?: Filter + filters?: Filter, ) => Request>( setURL( `${BETA_API_ROOT}/linode/instances/${encodeURIComponent( - linodeId - )}/interfaces/history` + linodeId, + )}/interfaces/history`, ), setMethod('GET'), setParams(params), - setXFilter(filters) + setXFilter(filters), ); /** @@ -101,10 +103,10 @@ export const getLinodeInterfacesSettings = (linodeId: number) => Request( setURL( `${BETA_API_ROOT}/linode/instances/${encodeURIComponent( - linodeId - )}/interfaces/settings` + linodeId, + )}/interfaces/settings`, ), - setMethod('GET') + setMethod('GET'), ); /** @@ -117,16 +119,16 @@ export const getLinodeInterfacesSettings = (linodeId: number) => */ export const updateLinodeInterfacesSettings = ( linodeId: number, - data: LinodeInterfaceSettingsPayload + data: LinodeInterfaceSettingsPayload, ) => Request( setURL( `${BETA_API_ROOT}/linode/instances/${encodeURIComponent( - linodeId - )}/interfaces/settings` + linodeId, + )}/interfaces/settings`, ), setMethod('PUT'), - setData(data, UpdateLinodeInterfaceSettingsSchema) + setData(data, UpdateLinodeInterfaceSettingsSchema), ); /** @@ -141,10 +143,10 @@ export const getLinodeInterface = (linodeId: number, interfaceId: number) => Request( setURL( `${BETA_API_ROOT}/linode/instances/${encodeURIComponent( - linodeId - )}/interfaces/${encodeURIComponent(interfaceId)}` + linodeId, + )}/interfaces/${encodeURIComponent(interfaceId)}`, ), - setMethod('GET') + setMethod('GET'), ); /** @@ -159,16 +161,16 @@ export const getLinodeInterface = (linodeId: number, interfaceId: number) => export const updateLinodeInterface = ( linodeId: number, interfaceId: number, - data: ModifyLinodeInterfacePayload + data: ModifyLinodeInterfacePayload, ) => Request( setURL( `${BETA_API_ROOT}/linode/instances/${encodeURIComponent( - linodeId - )}/interfaces/${encodeURIComponent(interfaceId)}` + linodeId, + )}/interfaces/${encodeURIComponent(interfaceId)}`, ), setMethod('PUT'), - setData(data, ModifyLinodeInterfaceSchema) + setData(data, ModifyLinodeInterfaceSchema), ); /** @@ -183,10 +185,10 @@ export const deleteLinodeInterface = (linodeId: number, interfaceId: number) => Request<{}>( setURL( `${BETA_API_ROOT}/linode/instances/${encodeURIComponent( - linodeId - )}/interfaces/${encodeURIComponent(interfaceId)}` + linodeId, + )}/interfaces/${encodeURIComponent(interfaceId)}`, ), - setMethod('DELETE') + setMethod('DELETE'), ); /** @@ -201,17 +203,17 @@ export const getLinodeInterfaceFirewalls = ( linodeId: number, interfaceId: number, params?: Params, - filters?: Filter + filters?: Filter, ) => Request>( setURL( `${BETA_API_ROOT}/linode/instances/${encodeURIComponent( - linodeId - )}/interfaces/${encodeURIComponent(interfaceId)}/firewalls` + linodeId, + )}/interfaces/${encodeURIComponent(interfaceId)}/firewalls`, ), setMethod('GET'), setParams(params), - setXFilter(filters) + setXFilter(filters), ); /** @@ -224,14 +226,14 @@ export const getLinodeInterfaceFirewalls = ( */ export const upgradeToLinodeInterface = ( linodeId: number, - data: UpgradeInterfacePayload + data: UpgradeInterfacePayload, ) => Request( setURL( `${BETA_API_ROOT}/linode/instances/${encodeURIComponent( - linodeId - )}/upgrade-interfaces` + linodeId, + )}/upgrade-interfaces`, ), setMethod('POST'), - setData(data, UpgradeToLinodeInterfaceSchema) + setData(data, UpgradeToLinodeInterfaceSchema), ); diff --git a/packages/api-v4/src/linodes/types.ts b/packages/api-v4/src/linodes/types.ts index bf83834138a..9d1975dcf85 100644 --- a/packages/api-v4/src/linodes/types.ts +++ b/packages/api-v4/src/linodes/types.ts @@ -1,18 +1,19 @@ -import type { Region, RegionSite } from '../regions'; import type { IPAddress, IPRange } from '../networking/types'; import type { LinodePlacementGroupPayload } from '../placement-groups/types'; -import { InferType } from 'yup'; -import { +import type { Region, RegionSite } from '../regions'; +import type { CreateLinodeInterfaceSchema, ModifyLinodeInterfaceSchema, RebuildLinodeSchema, UpdateLinodeInterfaceSettingsSchema, UpgradeToLinodeInterfaceSchema, } from '@linode/validation'; +import type { VPCIP } from 'src/vpcs'; +import type { InferType } from 'yup'; export type Hypervisor = 'kvm' | 'zen'; -export type EncryptionStatus = 'enabled' | 'disabled'; +export type EncryptionStatus = 'disabled' | 'enabled'; export type InterfaceGenerationType = 'legacy_config' | 'linode'; @@ -150,6 +151,7 @@ export interface LinodeIPsResponseIPV4 { private: IPAddress[]; shared: IPAddress[]; reserved: IPAddress[]; + vpc: VPCIP[]; } export interface LinodeIPsResponseIPV6 { @@ -693,10 +695,10 @@ export interface ResizeLinodePayload { migration_type?: MigrationTypes; } -export interface DeleteLinodeConfigInterfacePayload { - linodeId: number; - configId: number; +export interface DeleteInterfaceIds { + configId: null | number; interfaceId: number; + linodeId: number; } export interface LinodeLishData { diff --git a/packages/api-v4/src/request.ts b/packages/api-v4/src/request.ts index 926e6a5bd57..c63a5198048 100644 --- a/packages/api-v4/src/request.ts +++ b/packages/api-v4/src/request.ts @@ -11,6 +11,8 @@ interface RequestConfig extends AxiosRequestConfig { validationErrors?: APIError[]; } +type RequestConfigFn = (config: RequestConfig) => RequestConfig; + type ConfigField = 'headers' | 'data' | 'params' | 'method' | 'url'; export const baseRequest = Axios.create({ @@ -71,11 +73,13 @@ export const setMethod = (method: 'GET' | 'POST' | 'PUT' | 'DELETE') => /** Param */ export const setParams = (params: Params | undefined) => set('params', params); -export const setHeaders = (newHeaders: any = {}) => (object: any) => { - return !isEmpty(newHeaders) - ? { ...object, headers: { ...object.headers, ...newHeaders } } - : object; -}; +export const setHeaders = + (newHeaders: any = {}) => + (object: any) => { + return !isEmpty(newHeaders) + ? { ...object, headers: { ...object.headers, ...newHeaders } } + : object; + }; /** * Validate and set data in the request configuration object. @@ -93,7 +97,7 @@ export const setData = ( * object, after the validation has happened. Use with caution: It was created as a trap door for * merging IPv4 addresses and ports in the NodeBalancer creation flow. */ - postValidationTransform?: (_: any) => any + postValidationTransform?: (_: any) => any, ): any => { if (!schema) { return set('data', data); @@ -121,7 +125,7 @@ export const setData = ( * to itself since we have nested structures (think NodeBalancers). */ export const convertYupToLinodeErrors = ( - validationError: ValidationError + validationError: ValidationError, ): APIError[] => { const { inner } = validationError; @@ -169,18 +173,18 @@ export const setXFilter = (xFilter: Filter | undefined) => { * is an error. * @param fns An array of functions to be applied to the config object. */ -const reduceRequestConfig = (...fns: Function[]): RequestConfig => - fns.reduceRight((result, fn) => fn(result), { +const reduceRequestConfig = (...fns: RequestConfigFn[]): RequestConfig => + fns.reduceRight((result, fn) => fn(result), { url: 'https://api.linode.com/v4', headers: {}, }); /** Generator */ -export const requestGenerator = (...fns: Function[]): Promise => { +export const requestGenerator = (...fns: RequestConfigFn[]): Promise => { const config = reduceRequestConfig(...fns); if (config.validationErrors) { return Promise.reject( - config.validationErrors // All failed requests, client or server errors, should be APIError[] + config.validationErrors, // All failed requests, client or server errors, should be APIError[] ); } return baseRequest(config).then((response) => response.data); @@ -199,7 +203,7 @@ export const requestGenerator = (...fns: Function[]): Promise => { export const mockAPIError = ( status: number = 400, statusText: string = 'Internal Server Error', - data: any = {} + data: any = {}, ): Promise => new Promise((resolve, reject) => setTimeout( @@ -213,10 +217,10 @@ export const mockAPIError = ( config: { headers: new AxiosHeaders(), }, - }) + }), ), - process.env.NODE_ENV === 'test' ? 0 : 250 - ) + process.env.NODE_ENV === 'test' ? 0 : 250, + ), ); const createError = (message: string, response: AxiosResponse) => { @@ -231,7 +235,7 @@ export interface CancellableRequest { } export const CancellableRequest = ( - ...fns: Function[] + ...fns: RequestConfigFn[] ): CancellableRequest => { const config = reduceRequestConfig(...fns); const source = Axios.CancelToken.source(); @@ -251,7 +255,7 @@ export const CancellableRequest = ( cancel: source.cancel, request: () => baseRequest({ ...config, cancelToken: source.token }).then( - (response) => response.data + (response) => response.data, ), }; }; diff --git a/packages/manager/.eslintrc.cjs b/packages/manager/.eslintrc.cjs deleted file mode 100644 index fd5c9ac21e0..00000000000 --- a/packages/manager/.eslintrc.cjs +++ /dev/null @@ -1,460 +0,0 @@ -/* eslint-disable sonarjs/no-duplicate-string */ -module.exports = { - env: { - browser: true, - }, - extends: [ - // disables a few of the recommended rules from the previous set that we know are already covered by TypeScript's typechecker - 'plugin:@typescript-eslint/eslint-recommended', - // like eslint:recommended, except it only turns on rules from our TypeScript-specific plugin. - 'plugin:@typescript-eslint/recommended', - 'plugin:react/recommended', - 'plugin:react-hooks/recommended', - 'plugin:jsx-a11y/recommended', - 'plugin:sonarjs/recommended', - 'plugin:ramda/recommended', - 'plugin:cypress/recommended', - 'plugin:prettier/recommended', // Enables eslint-plugin-prettier and eslint-config-prettier. This will display prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array. - 'plugin:testing-library/react', - 'plugin:perfectionist/recommended-natural', - ], - ignorePatterns: [ - 'node_modules', - 'build', - 'storybook-static', - '.storybook', - 'public', - '!.eslintrc.js', - ], - overrides: [ - { - files: ['*.ts', '*.tsx'], - rules: { - '@typescript-eslint/ban-types': [ - 'error', - { - extendDefaults: true, - types: { - '{}': false, - }, - }, - ], - '@typescript-eslint/no-unused-vars': [ - 'warn', - { argsIgnorePattern: '^_' }, - ], - // eslint not typescript does a bad job with type aliases, we let typescript eslint do it - 'no-unused-vars': 'off', - }, - }, - { - files: ['*js'], - rules: { - '@typescript-eslint/explicit-function-return-type': 'off', - }, - }, - { - env: { node: true }, - // node files - files: [ - '**/*.test.*', - '**/*.spec.js', - '**/*.stories.js', - 'scripts/**', - 'config/**', - 'cypress/**', - ], - rules: { - '@typescript-eslint/no-empty-function': 'warn', // possible for tests - '@typescript-eslint/no-var-requires': 'off', - 'array-callback-return': 'off', - 'no-unused-expressions': 'off', - }, - }, - { - env: { - 'cypress/globals': true, - node: true, - }, - // scrips, config and cypress files can use console - files: ['scripts/**', 'config/**', 'cypress/**'], - rules: { - 'no-console': 'off', - // here we get false positives as cypress self handles async/await - 'testing-library/await-async-query': 'off', - }, - }, - { - env: { - 'cypress/globals': true, - node: true, - }, - // cypress/e2e/core files have had --fix applied, so enforce error level to maintain code quality - files: ['cypress/e2e/core/**'], - rules: { - '@typescript-eslint/consistent-type-imports': 'error', - 'perfectionist/sort-array-includes': 'error', - 'perfectionist/sort-classes': 'error', - 'perfectionist/sort-enums': 'error', - 'perfectionist/sort-exports': 'error', - 'perfectionist/sort-imports': [ - 'error', - { - 'custom-groups': { - type: { - react: ['react', 'react-*'], - src: ['src*'], - }, - value: { - src: ['src/**/*'], - }, - }, - groups: [ - ['builtin', 'libraries', 'external'], - ['src', 'internal'], - ['parent', 'sibling', 'index'], - 'object', - 'unknown', - [ - 'type', - 'internal-type', - 'parent-type', - 'sibling-type', - 'index-type', - ], - ], - 'newlines-between': 'always', - }, - ], - 'perfectionist/sort-interfaces': 'error', - 'perfectionist/sort-jsx-props': 'error', - 'perfectionist/sort-map-elements': 'error', - 'perfectionist/sort-named-exports': 'error', - 'perfectionist/sort-named-imports': 'error', - 'perfectionist/sort-object-types': 'error', - 'perfectionist/sort-objects': 'error', - 'perfectionist/sort-union-types': 'error', - }, - }, - // restrict usage of react-router-dom during migration to tanstack/react-router - // TODO: TanStack Router - remove this override when migration is complete - { - files: [ - // for each new features added to the migration router, add its directory here - 'src/features/Betas/**/*', - 'src/features/Domains/**/*', - 'src/features/Firewalls/**/*', - 'src/features/Images/**/*', - 'src/features/Longview/**/*', - 'src/features/NodeBalancers/**/*', - 'src/features/PlacementGroups/**/*', - 'src/features/StackScripts/**/*', - 'src/features/Volumes/**/*', - ], - rules: { - 'no-restricted-imports': [ - // This needs to remain an error however trying to link to a feature that is not yet migrated will break the router - // For those cases react-router-dom history.push is still needed - // using `eslint-disable-next-line no-restricted-imports` can help bypass those imports - 'error', - { - paths: [ - { - importNames: [ - // intentionally not including in this list as this will be updated last globally - 'useNavigate', - 'useParams', - 'useLocation', - 'useHistory', - 'useRouteMatch', - 'matchPath', - 'MemoryRouter', - 'Route', - 'RouteProps', - 'Switch', - 'Redirect', - 'RouteComponentProps', - 'withRouter', - ], - message: - 'Please use routing utilities intended for @tanstack/react-router.', - name: 'react-router-dom', - }, - { - importNames: ['TabLinkList'], - message: - 'Please use the TanStackTabLinkList component for components being migrated to TanStack Router.', - name: 'src/components/Tabs/TabLinkList', - }, - { - importNames: ['OrderBy', 'default'], - message: - 'Please use useOrderV2 hook for components being migrated to TanStack Router.', - name: 'src/components/OrderBy', - }, - { - importNames: ['Prompt'], - message: - 'Please use the TanStack useBlocker hook for components/features being migrated to TanStack Router.', - name: 'src/components/Prompt/Prompt', - }, - ], - }, - ], - }, - }, - // Apply `no-createLinode` rule to `cypress` related files only. - { - files: ['cypress/**'], - rules: { - '@linode/cloud-manager/no-createLinode': 'error', - }, - }, - ], - parser: '@typescript-eslint/parser', // Specifies the ESLint parser - parserOptions: { - // Warning if you want to set tsconfig.json, you ll need laso to set `tsconfigRootDir:__dirname` - // BUT we decided not to use this feature due to a very important performance impact - // project: 'tsconfig.json', - ecmaFeatures: { - jsx: true, - }, - // Only ESLint 6.2.0 and later support ES2020. - ecmaVersion: 2020, - warnOnUnsupportedTypeScriptVersion: true, - }, - plugins: [ - '@typescript-eslint', - 'react', - 'react-hooks', - 'jsx-a11y', - 'sonarjs', - 'ramda', - 'cypress', - 'prettier', - 'testing-library', - 'scanjs-rules', - 'xss', - 'perfectionist', - '@linode/eslint-plugin-cloud-manager', - 'react-refresh', - ], - rules: { - '@linode/cloud-manager/deprecate-formik': 'warn', - '@linode/cloud-manager/no-createLinode': 'off', - '@linode/cloud-manager/no-mui-theme-spacing': 'warn', - '@typescript-eslint/consistent-type-imports': 'warn', - '@typescript-eslint/explicit-function-return-type': 'off', - '@typescript-eslint/explicit-module-boundary-types': 'off', - '@typescript-eslint/interface-name-prefix': 'off', - '@typescript-eslint/naming-convention': [ - 'warn', - { - format: ['camelCase', 'UPPER_CASE', 'PascalCase'], - leadingUnderscore: 'allow', - selector: 'variable', - trailingUnderscore: 'allow', - }, - { - format: null, - modifiers: ['destructured'], - selector: 'variable', - }, - { - format: ['camelCase', 'PascalCase'], - selector: 'function', - }, - { - format: ['camelCase'], - leadingUnderscore: 'allow', - selector: 'parameter', - }, - { - format: ['PascalCase'], - selector: 'typeLike', - }, - ], - '@typescript-eslint/no-empty-interface': 'warn', - '@typescript-eslint/no-explicit-any': 'warn', - '@typescript-eslint/no-inferrable-types': 'off', - '@typescript-eslint/no-namespace': 'warn', - // this would disallow usage of ! postfix operator on non null types - '@typescript-eslint/no-non-null-assertion': 'off', - // This rules is disabled to avoid duplicates errors as no-unused-vars is set - '@typescript-eslint/no-unused-vars': 'off', - '@typescript-eslint/no-use-before-define': 'off', - 'array-callback-return': 'error', - 'comma-dangle': 'off', // Prettier and TS both handle and check for this one - // radix: Codacy considers it as an error, i put it here to fix it before push - curly: 'warn', - eqeqeq: 'warn', - // See: https://www.w3.org/TR/graphics-aria-1.0/ - 'jsx-a11y/aria-role': [ - 'error', - { - allowedInvalidRoles: [ - 'graphics-document', - 'graphics-object', - 'graphics-symbol', - ], - }, - ], - // typescript-eslint specific rules - 'no-await-in-loop': 'error', - 'no-bitwise': 'error', - 'no-caller': 'error', - 'no-console': 'error', - 'no-eval': 'error', - // turned off to allow arrow functions in React Class Component - 'no-invalid-this': 'off', - // loop rules - 'no-loop-func': 'error', - 'no-mixed-requires': 'warn', - // style errors - 'no-multiple-empty-lines': 'error', - 'no-new-wrappers': 'error', - 'no-restricted-imports': [ - 'error', - 'rxjs', - '@mui/core', - '@mui/system', - '@mui/icons-material', - { - importNames: ['Typography'], - message: - 'Please use Typography component from @linode/ui instead of @mui/material', - name: '@mui/material', - }, - { - importNames: ['Link'], - message: - 'Please use the Link component from src/components/Link instead of react-router-dom', - name: 'react-router-dom', - }, - ], - 'no-restricted-syntax': [ - 'error', - { - message: - "The 'data-test-id' attribute is not allowed; use 'data-testid' instead.", - selector: "JSXAttribute[name.name='data-test-id']", - }, - ], - 'no-throw-literal': 'warn', - 'no-trailing-spaces': 'warn', - // allowing to init vars to undefined - 'no-undef-init': 'off', - 'no-unused-expressions': 'warn', - // prepend `_` to an arg you accept to ignore - 'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], - 'object-shorthand': 'warn', - // Perfectionist - 'perfectionist/sort-array-includes': 'warn', - 'perfectionist/sort-classes': 'warn', - 'perfectionist/sort-enums': 'warn', - 'perfectionist/sort-exports': 'warn', - 'perfectionist/sort-imports': [ - 'warn', - { - 'custom-groups': { - type: { - react: ['react', 'react-*'], - src: ['src*'], - }, - value: { - src: ['src/**/*'], - }, - }, - groups: [ - ['builtin', 'libraries', 'external'], - ['src', 'internal'], - ['parent', 'sibling', 'index'], - 'object', - 'unknown', - [ - 'type', - 'internal-type', - 'parent-type', - 'sibling-type', - 'index-type', - ], - ], - 'newlines-between': 'always', - }, - ], - 'perfectionist/sort-interfaces': 'warn', - 'perfectionist/sort-jsx-props': 'warn', - 'perfectionist/sort-map-elements': 'warn', - 'perfectionist/sort-named-exports': 'warn', - 'perfectionist/sort-named-imports': 'warn', - 'perfectionist/sort-object-types': 'warn', - 'perfectionist/sort-objects': 'warn', - 'perfectionist/sort-union-types': 'warn', - // make prettier issues warnings - 'prettier/prettier': 'warn', - // radix requires to give the base in parseInt https://eslint.org/docs/rules/radix - radix: 'error', - // ramda - 'ramda/prefer-ramda-boolean': 'off', - // react and jsx specific rules - 'react/display-name': 'off', - 'react/jsx-no-bind': 'off', - 'react/jsx-no-script-url': 'error', - 'react/jsx-no-useless-fragment': 'warn', - 'react/no-unescaped-entities': 'warn', - // requires the definition of proptypes for react components - 'react/prop-types': 'off', - 'react/self-closing-comp': 'warn', - 'react-hooks/exhaustive-deps': 'warn', - 'react-hooks/rules-of-hooks': 'error', - 'react-refresh/only-export-components': 'warn', - 'scanjs-rules/assign_to_hostname': 'warn', - 'scanjs-rules/assign_to_href': 'warn', - 'scanjs-rules/assign_to_location': 'warn', - 'scanjs-rules/assign_to_onmessage': 'warn', - 'scanjs-rules/assign_to_pathname': 'warn', - 'scanjs-rules/assign_to_protocol': 'error', - 'scanjs-rules/assign_to_search': 'warn', - 'scanjs-rules/assign_to_src': 'warn', - // Allow roles from WAI-ARIA graphics module proposal. - 'scanjs-rules/call_Function': 'error', - // Prevent patterns susceptible to XSS, like '
' + userInput + '
'. - 'scanjs-rules/call_addEventListener': 'warn', - 'scanjs-rules/call_parseFromString': 'error', - 'scanjs-rules/new_Function': 'error', - 'scanjs-rules/property_geolocation': 'error', - // sonar - 'sonarjs/cognitive-complexity': 'off', - 'sonarjs/no-duplicate-string': 'warn', - 'sonarjs/no-identical-functions': 'warn', - 'sonarjs/no-redundant-jump': 'warn', - 'sonarjs/no-small-switch': 'warn', - 'sonarjs/prefer-immediate-return': 'warn', - 'sort-keys': 'off', - 'spaced-comment': 'warn', - // https://github.com/Rantanen/eslint-plugin-xss/blob/master/docs/rules/no-mixed-html.md - 'xss/no-mixed-html': [ - 'error', - { - // It's only valid to assign HTML to variables/attributes named "_html" (for React's - functions: { - // Declare "sanitizeHTML" as a function that accepts HTML as input and output, and that - // it's "safe", meaning callers can trust the output (but the output still can only be - // assigned to a variable with the naming convention above). - sanitizeHTML: { - htmlInput: true, - htmlOutput: true, - safe: true, - }, - }, - // dangerouslySetInnerHTML) and /sanitize/i (regex matching). - htmlVariableRules: ['__html', 'sanitize/i'], - }, - ], - }, - settings: { - react: { - version: 'detect', // Tells eslint-plugin-react to automatically detect the version of React to use - }, - }, -}; diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index 65464ddcda5..747afca7151 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,102 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2025-04-22] - v1.140.0 + +### Added: + +- Add `cache update` logic in alerts.ts query file ([#11969](https://github.com/linode/manager/pull/11969)) +- Display encryption status with lock icon in Image Edit Drawer ([#11993](https://github.com/linode/manager/pull/11993)) +- Legacy browser support for `url.canParse` ([#12010](https://github.com/linode/manager/pull/12010)) +- Introduced the Web Component library, used table as POC ([#12012](https://github.com/linode/manager/pull/12012)) + +### Changed: + +- Disable Autocomplete search on touch devices ([#11932](https://github.com/linode/manager/pull/11932)) +- Remove min length validation for tag and added validation for empty string ([#11944](https://github.com/linode/manager/pull/11944)) +- Update toast styling to Akamai Design System specs ([#11962](https://github.com/linode/manager/pull/11962)) +- Disable custom/template firewall toggle in Create Firewall form for restricted user and update other field restrictions ([#11973](https://github.com/linode/manager/pull/11973)) +- Update config label to follow the category.label format, rename Monitor tab ([#11987](https://github.com/linode/manager/pull/11987)) +- Update copy for Image Service Gen2 ((#11989, [#12031](https://github.com/linode/manager/pull/12031)) +- Update Notice component to Akamai Design System ([#12004](https://github.com/linode/manager/pull/12004)) +- Rename `Analytics` tab to `Metrics` tab on Linode details page ([#12007](https://github.com/linode/manager/pull/12007)) +- Update Assign Role panel UI ([#12038](https://github.com/linode/manager/pull/12038)) + +### Fixed: + +- Visual UI bug with Payment Amount adornment ([#11816](https://github.com/linode/manager/pull/11816)) +- Pagination for subnets in VPC Subnet table ([#11906](https://github.com/linode/manager/pull/11906)) +- IP incrementation in Subnet Create drawer ([#11906](https://github.com/linode/manager/pull/11906)) +- LKE-E related network requests on the NodeBalancer details page ([#11966](https://github.com/linode/manager/pull/11966)) +- Update grid width in CloudPulseDashboardLanding.tsx, Change time range preference key in GlobalFilter.tsx, Change maxHeight of applied filter box to 78px in CloudPulseAppliedFilter.tsx ([#11968](https://github.com/linode/manager/pull/11968)) +- Display appropriate message for OBJ Access Keys with `Limited Access` and `No Access` permissions ([#11975](https://github.com/linode/manager/pull/11975)) +- Bugs in sensitive data masking in Longview, LKE node pools, Domains, and Linode details ([#12003](https://github.com/linode/manager/pull/12003)) +- DBaaS: Fixed dropdown autofill, error persistence on drawer reopen, missing validation for default_time_zone, and improved API error field mapping ([#12006](https://github.com/linode/manager/pull/12006)) +- ACL no longer renders for E2/E2 endpoints on page load ([#12011](https://github.com/linode/manager/pull/12011)) +- Missing warning message in the Images Landing page for a restricted user ([#12019](https://github.com/linode/manager/pull/12019)) +- Missing warning message in the Longview landing page for the restricted user ([#12021](https://github.com/linode/manager/pull/12021)) +- DBaaS: incorrect restart-related label on Save button, autofill not applying values, and API errors not clearing on config field blur ([#12032](https://github.com/linode/manager/pull/12032)) + +### Removed: + +- Move `getUserTimeZone` and its associated profile factories to `@linode/utilities` ([#11955](https://github.com/linode/manager/pull/11955)) +- Move `betaUtils` and its associated factories to `utilities` package ([#11986](https://github.com/linode/manager/pull/11986)) +- Truncation from PDF descriptions ([#12009](https://github.com/linode/manager/pull/12009)) +- Move `grants` and its associated factories to `utilities` package ([#12025](https://github.com/linode/manager/pull/12025)) +- Deprecate WarpSpeed, UTunnel, VictoriaMetrics, Seatable Marketplace apps ([#12048](https://github.com/linode/manager/pull/12048)) + +### Tech Stories: + +- VPC rerouting (TanStack) ([#11906](https://github.com/linode/manager/pull/11906)) +- Migrate Object Storage to Tanstack Router ([#11924](https://github.com/linode/manager/pull/11924)) +- Eslint Overhaul ([#11941](https://github.com/linode/manager/pull/11941)) +- Add MSW crud operations for Nodebalancers ([#11964](https://github.com/linode/manager/pull/11964)) +- Upgrade Cypress to 14.3.0 ([#12002](https://github.com/linode/manager/pull/12002)) +- Use Simple select component in `RegionTypeFilter` ([#12018](https://github.com/linode/manager/pull/12018)) + +### Tests: + +- Add database configuration to test 2 node cluster and validate dbaas v2 create/summary view ([#11928](https://github.com/linode/manager/pull/11928)) +- Add `env:marketplaceApps`, `env:multipleRegions`, and `env:stackScripts` tags for Cypress tests ([#11958](https://github.com/linode/manager/pull/11958)) +- Avoid selecting regions that do not support Machine Images in Image upload tests ([#11961](https://github.com/linode/manager/pull/11961)) +- Replace hardcoded region IDs in clone linode test ([#11992](https://github.com/linode/manager/pull/11992)) +- Remove hardcoded region in LKE test ([#11996](https://github.com/linode/manager/pull/11996)) +- Use mock regions as constraint for region search ([#11997](https://github.com/linode/manager/pull/11997)) +- Use mock region for linode config tests ([#11999](https://github.com/linode/manager/pull/11999)) +- Fix LKE update tests in DevCloud ([#12014](https://github.com/linode/manager/pull/12014)) +- Allow plan selection tests to pass in non-Production environments ([#12023](https://github.com/linode/manager/pull/12023)) +- Allow Linode delete tests to pass against non-Prod environments ([#12030](https://github.com/linode/manager/pull/12030)) +- Add Cypress tests to cover Firewall create flows using templates ([#12036](https://github.com/linode/manager/pull/12036)) +- Add Firewall landing page tests to cover Linode Interfaces improvements ([#12040](https://github.com/linode/manager/pull/12040)) + +### Upcoming Features: + +- Enhance schema validation for CloudPulse create and edit alert flow and avoid type assertions ([#11868](https://github.com/linode/manager/pull/11868)) +- Disable Upgrade Interfaces feature for LKE Linodes and other conditions ([#11934](https://github.com/linode/manager/pull/11934)) +- Enhance CloudPulse alerting resource selection section with maximum selection limitations ([#11943](https://github.com/linode/manager/pull/11943)) +- Fix SubnetLinodeRow for Linodes using new interfaces ([#11953](https://github.com/linode/manager/pull/11953)) +- Add Edit Public Linode Interface Drawer ([#11957](https://github.com/linode/manager/pull/11957)) +- UI bugfixes: Resetting Trigger Occurences, Resources values when service type is cleared, Disabling Trigger Occurences, Threshold values unless Service Type is selected. Added Max value for Trigger Occurences and Threshold TextField components ([#11963](https://github.com/linode/manager/pull/11963)) +- Remove `or` condition in filtering of `/instances` call at CloudPulse Metrics ([#11967](https://github.com/linode/manager/pull/11967)) +- Feature flag for VM Host Maintenance policy ([#11974](https://github.com/linode/manager/pull/11974)) +- Fix Linode Interface related VPC bugs in Linode Entity Detail and IP Addresses table ([#11976](https://github.com/linode/manager/pull/11976)) +- Rename resources to entities in labels, placeholders, messages and warnings in `cloudpulse alerting` section ([#11977](https://github.com/linode/manager/pull/11977)) +- Add a new drawer for updating entities iam ([#11978](https://github.com/linode/manager/pull/11978)) +- Add `Confirmation Dialog` in `AlertListTable.tsx`, add `message` prop in `AlertConfirmationDialog.tsx` ([#11981](https://github.com/linode/manager/pull/11981)) +- Fix displaying empty state when user doesn't have the assigned roles in iam ([#11984](https://github.com/linode/manager/pull/11984)) +- Update UI of the Networking section on the Linode Create flow ([#11985](https://github.com/linode/manager/pull/11985)) +- Check for Linode Interfaces Account Capability ([#11995](https://github.com/linode/manager/pull/11995)) +- add a new drawer for updating role for entity ([#11998](https://github.com/linode/manager/pull/11998)) +- Add `group by tag` feature for alerts in CloudPulse ([#12001](https://github.com/linode/manager/pull/12001)) +- Support more VPC features in the Add Interface Drawer ([#12008](https://github.com/linode/manager/pull/12008)) +- Add support for Linode Interfaces in Subnet Assign and Unassign drawers ([#12016](https://github.com/linode/manager/pull/12016)) +- Add Interface Settings Drawer for Linode Interfaces ([#12017](https://github.com/linode/manager/pull/12017)) +- Feature flag for ACLP Integration ([#12026](https://github.com/linode/manager/pull/12026)) +- IAM: Add a new confirmation dialog for removing entity for the role ([#12027](https://github.com/linode/manager/pull/12027)) +- Fix incorrect max autoscaler limit validation for LKE-E ([#12033](https://github.com/linode/manager/pull/12033)) +- Add an API check to the useIsIAMEnabled hook ([#12044](https://github.com/linode/manager/pull/12044)) +- Implement IAM Roles table ([#12012](https://github.com/linode/manager/pull/12012)) + ## [2025-04-16] - v1.139.1 ### Removed: @@ -12,7 +108,6 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [2025-04-08] - v1.139.0 - ### Added: - Add cache update logic on edit alert query ([#11917](https://github.com/linode/manager/pull/11917)) @@ -53,7 +148,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Move ramda dependent utils ([#11913](https://github.com/linode/manager/pull/11913)) - Move `useIsGeckoEnabled` hook out of `RegionSelect` to `@linode/shared` package ([#11918](https://github.com/linode/manager/pull/11918)) - Remove region selector from Edit VPC drawer since data center assignment cannot be changed. ([#11929](https://github.com/linode/manager/pull/11929)) -- DBaaS: deprecated types, outdated and unused code in DatabaseCreate and DatabaseSummary ([#11909](https://github.com/linode/manager/pull/11909)) +- DBaaS: deprecated types, outdated and unused code in DatabaseCreate and DatabaseSummary ([#11909](https://github.com/linode/manager/pull/11909)) - Move `useFormattedDate` from `manager` to `utilities` package ([#11931](https://github.com/linode/manager/pull/11931)) - Move stackscripts-related queries and dependencies to shared `queries` package ([#11949](https://github.com/linode/manager/pull/11949)) @@ -62,10 +157,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Make `RegionSelect` and `RegionMultiSelect` pure ([#11790](https://github.com/linode/manager/pull/11790)) - Nodebalancer routing (Tanstack) ([#11858](https://github.com/linode/manager/pull/11858)) - Add `FirewallSelect` component ([#11887](https://github.com/linode/manager/pull/11887)) -- Add eslint rule for deprecating mui theme.spacing ([#11889](https://github.com/linode/manager/pull/11889)) +- Add eslint rule for deprecating mui theme.spacing ([#11889](https://github.com/linode/manager/pull/11889)) - Resolve Path Traversal Vulnerabilities detected from semgrep ([#11914](https://github.com/linode/manager/pull/11914)) - Move feature flag code out of Kubernetes queries file ([#11922](https://github.com/linode/manager/pull/11922)) -- Fix incorrect secret in `publish-packages` Github Action ([#11923](https://github.com/linode/manager/pull/11923)) +- Fix incorrect secret in `publish-packages` Github Action ([#11923](https://github.com/linode/manager/pull/11923)) - Remove hashing on Pendo account and visitor ids ([#11950](https://github.com/linode/manager/pull/11950)) ### Tests: @@ -116,7 +211,6 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [2025-03-25] - v1.138.0 - ### Added: - LKE UI updates for checkout bar & NodeBalancer Details summary ([#11653](https://github.com/linode/manager/pull/11653)) @@ -226,7 +320,6 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Fix Google Pay test failures when using Braintree sandbox environment (#11863) - Apply new custom eslint rule and lint files (#11689, #11722, #11730, #11756, #11766, #11814) - ### Upcoming Features: - Build new Quotas Controls ([#11647](https://github.com/linode/manager/pull/11647)) @@ -261,7 +354,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Add Upgrade Interfaces dialog for Linodes using legacy Configuration Profile Interfaces ([#11808](https://github.com/linode/manager/pull/11808)) - Disable Akamai App Platform beta for LKE-E clusters on create flow ([#11809](https://github.com/linode/manager/pull/11809)) - Handle errors while enabling and disabling alerts in Monitor at `AlertListTable.tsx` ([#11813](https://github.com/linode/manager/pull/11813)) -- Set `refetchInterval` for 2 mins in CloudPulse alert queries ([#11815](https://github.com/linode/manager/pull/11815)) +- Set `refetchInterval` for 2 mins in CloudPulse alert queries ([#11815](https://github.com/linode/manager/pull/11815)) - Add resources selection limitation in CloudPulse Alerting resources section for create and edit flows ([#11823](https://github.com/linode/manager/pull/11823)) - Remove `sxEndIcon` prop from Add Metric, Dimension Filter and Notification Channel buttons ([#11825](https://github.com/linode/manager/pull/11825)) - Add query to update roles in IAM ([#11840](https://github.com/linode/manager/pull/11840)) @@ -271,7 +364,6 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Remove toggle in the 'Add A User' drawer and default to limited access for users for IAM ([#11870](https://github.com/linode/manager/pull/11870)) - Update LKE-E flows to account for LDE status at LA launch ([#11880](https://github.com/linode/manager/pull/11880)) - ## [2025-02-27] - v1.137.2 ### Fixed: @@ -284,10 +376,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Unable to save non-US billing contact information without tax id ([#11725](https://github.com/linode/manager/pull/11725)) - ## [2025-02-25] - v1.137.0 - ### Added: - Improved Node Pool Collapsing UX ([#11619](https://github.com/linode/manager/pull/11619)) @@ -317,7 +407,6 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Migrate `Dialog`, `DialogTitle` components, and `visibilityHide.svg`, `visibilityShow.svg`, and `chevron-down.svg` icons to the `@linode/ui` package ([#11673](https://github.com/linode/manager/pull/11673)) - `react-select` from the codebase ([#11601](https://github.com/linode/manager/pull/11601)) - ### Tech Stories: - Improve consistency of Notice error states ([#11404](https://github.com/linode/manager/pull/11404)) @@ -370,7 +459,6 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [2025-02-11] - v1.136.0 - ### Added: - Labels and Taints to LKE Node Pools ([#11528](https://github.com/linode/manager/pull/11528), [#11553](https://github.com/linode/manager/pull/11553)) @@ -378,7 +466,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - LKE cluster label and id on associated Linode's details page ([#11568](https://github.com/linode/manager/pull/11568)) - Visual indication for unencrypted images ([#11579](https://github.com/linode/manager/pull/11579)) - Collapsible Node Pool tables & filterable status ([#11589](https://github.com/linode/manager/pull/11589)) -- Database status display and event notifications for database migration ([#11590](https://github.com/linode/manager/pull/11590)) +- Database status display and event notifications for database migration ([#11590](https://github.com/linode/manager/pull/11590)) - Database migration info banner ([#11595](https://github.com/linode/manager/pull/11595)) ### Changed: @@ -398,7 +486,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Tech Stories: - Refactor routing for Placement Groups to use Tanstack Router ([#11474](https://github.com/linode/manager/pull/11474)) -- Replace ramda's `pathOr` with custom utility ([#11512](https://github.com/linode/manager/pull/11512)) +- Replace ramda's `pathOr` with custom utility ([#11512](https://github.com/linode/manager/pull/11512)) - Refactor StackScript Create, Edit, and Details pages ([#11532](https://github.com/linode/manager/pull/11532)) - Upgrade Vite to v6 ([#11548](https://github.com/linode/manager/pull/11548)) - Upgrade Vitest to v3 ([#11548](https://github.com/linode/manager/pull/11548)) @@ -459,7 +547,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Improve backups banner styles ([#11480](https://github.com/linode/manager/pull/11480)) - Disable resizable plans when the usable storage equals the used storage of the database cluster ([#11481](https://github.com/linode/manager/pull/11481)) -([#11495](https://github.com/linode/manager/pull/11495)) + ([#11495](https://github.com/linode/manager/pull/11495)) - Tech doc link for Bucket rate limits ([#11513](https://github.com/linode/manager/pull/11513)) - Search v2 `not equal` syntax ([#11521](https://github.com/linode/manager/pull/11521)) - Revise Disk Encryption description copy in Linode Create flow ([#11536](https://github.com/linode/manager/pull/11536)) @@ -484,7 +572,6 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Refactor and convert DomainRecords to functional component ([#11447](https://github.com/linode/manager/pull/11447)) - Add `Asia/Calcutta` zonename in `timezones.ts`, `disabledTimeZone` property in `DateTimeRangePicker`, and `minDate` property to `DateTimePicker` ([#11495](https://github.com/linode/manager/pull/11495)) - ### Tests: - Improve organization of Object Storage and Object Storage Multicluster tests ([#11484](https://github.com/linode/manager/pull/11484)) @@ -507,7 +594,6 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Add `AddNotificationChannel` component with unit tests with necessary changes for constants, `CreateAlertDefinition` and other components. ([#11511](https://github.com/linode/manager/pull/11511)) - Add Quotas feature flag, queries, and MSW CRUD preset support ([#11493](https://github.com/linode/manager/pull/11493)) - ## [2025-01-14] - v1.134.0 ### Added: @@ -539,7 +625,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Account Cancellation Survey Button Color Issues ([#11412](https://github.com/linode/manager/pull/11412)) - DBaaS Manage Access IP fields are displaying an IPv4 validation error message when both IPv6 and IPv4 are available. ([#11414](https://github.com/linode/manager/pull/11414)) - `RegionHelperText` causing console errors ([#11416](https://github.com/linode/manager/pull/11416)) -- Linode Edit Config warning message when initially selecting a VPC as the primary interface ([#11424](https://github.com/linode/manager/pull/11424)) +- Linode Edit Config warning message when initially selecting a VPC as the primary interface ([#11424](https://github.com/linode/manager/pull/11424)) - DBaaS Resize tab Used field is displaying just GB on provisioning database cluster ([#11426](https://github.com/linode/manager/pull/11426)) - Various bugs in Managed tables ([#11431](https://github.com/linode/manager/pull/11431)) - ARIA label of action menu in Domains Landing table row ([#11437](https://github.com/linode/manager/pull/11437)) @@ -574,7 +660,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Add MSW crud domains ([#11428](https://github.com/linode/manager/pull/11428)) - Replace react-select instances in /Users with new Select ([#11430](https://github.com/linode/manager/pull/11430)) - Fixed CloudPulse metric definition types ([#11433](https://github.com/linode/manager/pull/11433)) -- Patch `cookie` version as resolution for dependabot ([#11434](https://github.com/linode/manager/pull/11434)) +- Patch `cookie` version as resolution for dependabot ([#11434](https://github.com/linode/manager/pull/11434)) - Replace Select with Autocomplete component in Object Storage ([#11456](https://github.com/linode/manager/pull/11456)) - Update `react-vnc` to 2.0.2 ([#11467](https://github.com/linode/manager/pull/11467)) @@ -590,7 +676,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Improve assertions made in `smoke-billing-activity.spec.ts` ([#11394](https://github.com/linode/manager/pull/11394)) - Clean up `DatabaseBackups.test.tsx` ([#11394](https://github.com/linode/manager/pull/11394)) - Fix account login and logout tests when using non-Prod environment ([#11407](https://github.com/linode/manager/pull/11407)) -- Add Cypress component tests for Autocomplete ([#11408](https://github.com/linode/manager/pull/11408)) +- Add Cypress component tests for Autocomplete ([#11408](https://github.com/linode/manager/pull/11408)) - Update mock region for LKE cluster creation test ([#11411](https://github.com/linode/manager/pull/11411)) - Cypress tests to validate errors in Linode Create Backups tab ([#11422](https://github.com/linode/manager/pull/11422)) - Cypress test to validate aria label of Linode IP Addresses action menu ([#11435](https://github.com/linode/manager/pull/11435)) @@ -616,7 +702,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - High performance volume indicator ([#11400](https://github.com/linode/manager/pull/11400)) - Add new no assigned roles component for IAM ([#11401](https://github.com/linode/manager/pull/11401)) - Fix invalid routes in the IAM ([#11436](https://github.com/linode/manager/pull/11436)) -- Initial support for NodeBalancer UDP protocol ([#11405](https://github.com/linode/manager/pull/11405)) +- Initial support for NodeBalancer UDP protocol ([#11405](https://github.com/linode/manager/pull/11405)) - Add support for new optional filter - 'Tags' in monitor ([#11457](https://github.com/linode/manager/pull/11457)) - Show ACLP supported regions per service type in region select ([#11382](https://github.com/linode/manager/pull/11382)) - Add `CloudPulseAppliedFilter` and `CloudPulseAppliedFilterRenderer` components, update filter change handler function to add another parameter `label` ([#11354](https://github.com/linode/manager/pull/11354)) @@ -635,7 +721,6 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Incorrectly displayed region options ([#11449](https://github.com/linode/manager/pull/11449)) - ## [2024-12-19] - v1.133.1 ### Fixed: @@ -643,7 +728,6 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Filter available regions in Object Gen2 Create Drawer and Access Keys List based on endpoint capabilities ([#11432](https://github.com/linode/manager/pull/11432)) - Region name display in Gen2 warning notices when regions are unavailable due to format mismatch ([#11432](https://github.com/linode/manager/pull/11432)) - ## [2024-12-10] - v1.133.0 ### Added: diff --git a/packages/manager/Dockerfile b/packages/manager/Dockerfile index d56a7a2fb2e..8bdc220a59b 100644 --- a/packages/manager/Dockerfile +++ b/packages/manager/Dockerfile @@ -6,7 +6,7 @@ ARG IMAGE_REGISTRY=docker.io ARG NODE_VERSION=20.17.0 # Cypress version. -ARG CYPRESS_VERSION=14.0.1 +ARG CYPRESS_VERSION=14.3.0 # Node.js base image for Cloud Manager CI tasks. # diff --git a/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts b/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts index 728ea5695d7..78797cd18bb 100644 --- a/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts +++ b/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts @@ -2,6 +2,7 @@ * @file Integration tests for Cloud Manager account cancellation flows. */ +import { profileFactory } from '@linode/utilities'; import { cancellationDataLossWarning, cancellationDialogTitle, @@ -22,7 +23,6 @@ import { } from 'support/util/random'; import { accountFactory } from 'src/factories/account'; -import { profileFactory } from 'src/factories/profile'; import { CHILD_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT, PARENT_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT, diff --git a/packages/manager/cypress/e2e/core/account/account-linode-managed.spec.ts b/packages/manager/cypress/e2e/core/account/account-linode-managed.spec.ts index 118eb70095d..163164c7ee3 100644 --- a/packages/manager/cypress/e2e/core/account/account-linode-managed.spec.ts +++ b/packages/manager/cypress/e2e/core/account/account-linode-managed.spec.ts @@ -2,7 +2,7 @@ * @file Integration tests for Cloud Manager account enable Linode Managed flows. */ -import { linodeFactory } from '@linode/utilities'; +import { linodeFactory, profileFactory } from '@linode/utilities'; import { visitUrlWithManagedDisabled, visitUrlWithManagedEnabled, @@ -22,7 +22,6 @@ import { ui } from 'support/ui'; import { chooseRegion } from 'support/util/regions'; import { accountFactory } from 'src/factories/account'; -import { profileFactory } from 'src/factories/profile'; import type { Linode } from '@linode/api-v4'; @@ -38,14 +37,14 @@ describe('Account Linode Managed', () => { restricted: false, username: 'mock-user', }); - const mockLinodes = new Array(5).fill(null).map( - (item: null, index: number): Linode => { + const mockLinodes = new Array(5) + .fill(null) + .map((item: null, index: number): Linode => { return linodeFactory.build({ label: `Linode ${index}`, region: chooseRegion().id, }); - } - ); + }); mockGetLinodes(mockLinodes).as('getLinodes'); mockGetAccount(mockAccount).as('getAccount'); diff --git a/packages/manager/cypress/e2e/core/account/account-login-history.spec.ts b/packages/manager/cypress/e2e/core/account/account-login-history.spec.ts index b53a18ef7d0..e18840149e9 100644 --- a/packages/manager/cypress/e2e/core/account/account-login-history.spec.ts +++ b/packages/manager/cypress/e2e/core/account/account-login-history.spec.ts @@ -2,6 +2,7 @@ * @file Integration tests for Cloud Manager account login history flows. */ +import { profileFactory } from '@linode/utilities'; import { loginEmptyStateMessageText, loginHelperText, @@ -9,7 +10,6 @@ import { import { mockGetAccountLogins } from 'support/intercepts/account'; import { mockGetProfile } from 'support/intercepts/profile'; -import { profileFactory } from 'src/factories'; import { accountLoginFactory } from 'src/factories/accountLogin'; import { PARENT_USER } from 'src/features/Account/constants'; import { formatDate } from 'src/utilities/formatDate'; diff --git a/packages/manager/cypress/e2e/core/account/display-settings.spec.ts b/packages/manager/cypress/e2e/core/account/display-settings.spec.ts index 64f950dd149..e5b0e1655c6 100644 --- a/packages/manager/cypress/e2e/core/account/display-settings.spec.ts +++ b/packages/manager/cypress/e2e/core/account/display-settings.spec.ts @@ -1,4 +1,4 @@ -import { profileFactory } from '@src/factories'; +import { profileFactory } from '@linode/utilities'; import { getProfile } from 'support/api/account'; import { mockUpdateUsername } from 'support/intercepts/account'; import { interceptGetProfile } from 'support/intercepts/profile'; diff --git a/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts b/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts index c133b491d55..9e5554532c1 100644 --- a/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts +++ b/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts @@ -2,6 +2,7 @@ * @file Integration tests for personal access token CRUD operations. */ +import { profileFactory } from '@linode/utilities'; import { mockCreatePersonalAccessToken, mockGetAppTokens, @@ -14,7 +15,6 @@ import { ui } from 'support/ui'; import { randomLabel, randomString } from 'support/util/random'; import { appTokenFactory } from 'src/factories/oauth'; -import { profileFactory } from 'src/factories/profile'; import { PROXY_USER_RESTRICTED_TOOLTIP_TEXT } from 'src/features/Account/constants'; import type { Token } from '@linode/api-v4'; diff --git a/packages/manager/cypress/e2e/core/account/security-questions.spec.ts b/packages/manager/cypress/e2e/core/account/security-questions.spec.ts index 3d161c672b4..f2746a5377e 100644 --- a/packages/manager/cypress/e2e/core/account/security-questions.spec.ts +++ b/packages/manager/cypress/e2e/core/account/security-questions.spec.ts @@ -2,6 +2,7 @@ * @file Integration tests for account security questions. */ +import { profileFactory, securityQuestionsFactory } from '@linode/utilities'; import { mockGetProfile, mockGetSecurityQuestions, @@ -9,9 +10,6 @@ import { } from 'support/intercepts/profile'; import { ui } from 'support/ui'; -import { securityQuestionsFactory } from 'src/factories/profile'; -import { profileFactory } from 'src/factories/profile'; - /** * Finds the "Security Questions" section on the profile auth page. * diff --git a/packages/manager/cypress/e2e/core/account/smoke-enroll-beta.spec.ts b/packages/manager/cypress/e2e/core/account/smoke-enroll-beta.spec.ts index 17bfa535985..0353dfd793b 100644 --- a/packages/manager/cypress/e2e/core/account/smoke-enroll-beta.spec.ts +++ b/packages/manager/cypress/e2e/core/account/smoke-enroll-beta.spec.ts @@ -1,4 +1,4 @@ -import { accountBetaFactory, betaFactory } from '@src/factories'; +import { accountBetaFactory, betaFactory } from '@linode/utilities'; import { DateTime } from 'luxon'; import { authenticate } from 'support/api/authentication'; import { diff --git a/packages/manager/cypress/e2e/core/account/sms-verification.spec.ts b/packages/manager/cypress/e2e/core/account/sms-verification.spec.ts index 6a5ed1aed43..74f59ac48a7 100644 --- a/packages/manager/cypress/e2e/core/account/sms-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/account/sms-verification.spec.ts @@ -2,6 +2,7 @@ * @file Integration tests for SMS phone verification. */ +import { profileFactory } from '@linode/utilities'; import { mockGetProfile, mockSendVerificationCode, @@ -15,7 +16,6 @@ import { randomPhoneNumber, } from 'support/util/random'; -import { profileFactory } from 'src/factories/profile'; import { getFormattedNumber } from 'src/features/Profile/AuthenticationSettings/PhoneVerification/helpers'; describe('SMS phone verification', () => { diff --git a/packages/manager/cypress/e2e/core/account/ssh-keys.spec.ts b/packages/manager/cypress/e2e/core/account/ssh-keys.spec.ts index 5afeac8936c..cf24e9644d9 100644 --- a/packages/manager/cypress/e2e/core/account/ssh-keys.spec.ts +++ b/packages/manager/cypress/e2e/core/account/ssh-keys.spec.ts @@ -1,3 +1,4 @@ +import { sshKeyFactory } from '@linode/utilities'; import { sshFormatErrorMessage } from 'support/constants/account'; import { mockCreateSSHKey, @@ -9,8 +10,6 @@ import { import { ui } from 'support/ui'; import { randomLabel, randomString } from 'support/util/random'; -import { sshKeyFactory } from 'src/factories'; - describe('SSH keys', () => { /* * - Vaildates SSH key creation flow using mock data. diff --git a/packages/manager/cypress/e2e/core/account/two-factor-auth.spec.ts b/packages/manager/cypress/e2e/core/account/two-factor-auth.spec.ts index 9f1e945cb5a..75001e64ccf 100644 --- a/packages/manager/cypress/e2e/core/account/two-factor-auth.spec.ts +++ b/packages/manager/cypress/e2e/core/account/two-factor-auth.spec.ts @@ -2,6 +2,7 @@ * @file Integration tests for account two-factor authentication functionality. */ +import { profileFactory, securityQuestionsFactory } from '@linode/utilities'; import { mockConfirmTwoFactorAuth, mockDisableTwoFactorAuth, @@ -17,11 +18,6 @@ import { randomString, } from 'support/util/random'; -import { - profileFactory, - securityQuestionsFactory, -} from 'src/factories/profile'; - import type { SecurityQuestionsData } from '@linode/api-v4'; /** diff --git a/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts b/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts index ccaa315dfa9..366b58fe60a 100644 --- a/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts +++ b/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts @@ -1,6 +1,5 @@ -import { profileFactory } from '@src/factories'; +import { grantsFactory, profileFactory } from '@linode/utilities'; import { accountUserFactory } from '@src/factories/accountUsers'; -import { grantsFactory } from '@src/factories/grants'; import { userPermissionsGrants } from 'support/constants/user-permissions'; import { mockGetUser, @@ -581,7 +580,7 @@ describe('User permission management', () => { ); // Confirm that no "Profile" tab is present on the proxy user's User Permissions page. - expect(cy.findByText('User Profile').should('not.exist')); + cy.findByText('User Profile').should('not.exist'); cy.get('[data-qa-global-section]') .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/account/user-verification-banner.spec.ts b/packages/manager/cypress/e2e/core/account/user-verification-banner.spec.ts index d2547970203..d9de39ecb13 100644 --- a/packages/manager/cypress/e2e/core/account/user-verification-banner.spec.ts +++ b/packages/manager/cypress/e2e/core/account/user-verification-banner.spec.ts @@ -1,6 +1,9 @@ -import { profileFactory, securityQuestionsFactory } from '@src/factories'; +import { + grantsFactory, + profileFactory, + securityQuestionsFactory, +} from '@linode/utilities'; import { accountUserFactory } from '@src/factories/accountUsers'; -import { grantsFactory } from '@src/factories/grants'; import { verificationBannerNotice } from 'support/constants/user'; import { mockGetUser, diff --git a/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts b/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts index af4179b74c5..015ace12186 100644 --- a/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts @@ -1,6 +1,5 @@ -import { profileFactory } from '@src/factories'; +import { grantsFactory, profileFactory } from '@linode/utilities'; import { accountUserFactory } from '@src/factories/accountUsers'; -import { grantsFactory } from '@src/factories/grants'; import { mockAddUser, mockDeleteUser, @@ -31,13 +30,12 @@ import type { Profile } from '@linode/api-v4'; const initTestUsers = (profile: Profile, enableChildAccountAccess: boolean) => { const mockProfile = profile; - const mockRestrictedParentWithoutChildAccountAccess = accountUserFactory.build( - { + const mockRestrictedParentWithoutChildAccountAccess = + accountUserFactory.build({ restricted: true, user_type: 'parent', username: 'restricted-parent-user-without-child-account-access', - } - ); + }); const mockRestrictedParentWithChildAccountAccess = accountUserFactory.build({ restricted: true, diff --git a/packages/manager/cypress/e2e/core/billing/billing-invoices.spec.ts b/packages/manager/cypress/e2e/core/billing/billing-invoices.spec.ts index b00ce254e9e..1c419543396 100644 --- a/packages/manager/cypress/e2e/core/billing/billing-invoices.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/billing-invoices.spec.ts @@ -168,14 +168,15 @@ describe('Account invoices', () => { // If the invoice item has a region, confirm that it is displayed // in the table row. Otherwise, confirm that the table cell which // would normally show the region is empty. - !!invoiceItem.region - ? cy - .findByText(getRegionLabel(invoiceItem.region)) - .should('be.visible') - : cy - .get('[data-qa-region]') - .should('be.visible') - .should('be.empty'); + if (invoiceItem.region) { + cy.findByText(getRegionLabel(invoiceItem.region)).should( + 'be.visible' + ); + } else { + cy.get('[data-qa-region]') + .should('be.visible') + .should('be.empty'); + } }); } ); @@ -198,11 +199,13 @@ describe('Account invoices', () => { // If the invoice item has a region, confirm that it is displayed // in the table row. Otherwise, confirm that "Global" is displayed // in the region column. - !!invoiceItem.region - ? cy - .findByText(getRegionLabel(invoiceItem.region)) - .should('be.visible') - : cy.findByText('Global').should('be.visible'); + if (invoiceItem.region) { + cy.findByText(getRegionLabel(invoiceItem.region)).should( + 'be.visible' + ); + } else { + cy.findByText('Global').should('be.visible'); + } }); } ); diff --git a/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts b/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts index 4c55cefadfa..3e00dec7a13 100644 --- a/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts @@ -2,9 +2,9 @@ * @file Integration tests for restricted user billing flows. */ -import { paymentMethodFactory, profileFactory } from '@src/factories'; +import { grantsFactory, profileFactory } from '@linode/utilities'; +import { paymentMethodFactory } from '@src/factories'; import { accountUserFactory } from '@src/factories/accountUsers'; -import { grantsFactory } from '@src/factories/grants'; import { mockGetPaymentMethods, mockGetUser } from 'support/intercepts/account'; import { mockGetProfile, diff --git a/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts b/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts index 9189a234d17..73fb4893f57 100644 --- a/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts @@ -1,5 +1,5 @@ import { getProfile } from '@linode/api-v4'; -import { profileFactory } from '@src/factories'; +import { profileFactory } from '@linode/utilities'; import { formatDate } from '@src/utilities/formatDate'; import { DateTime } from 'luxon'; import { authenticate } from 'support/api/authentication'; @@ -125,24 +125,21 @@ describe('Billing Activity Feed', () => { * - Confirms that clicking on an invoice's label directs the user to the invoice details page. */ it('lists invoices and payments', () => { - const invoiceMocks = buildArray( - 10, - (i: number): Invoice => { - const id = randomNumber(1, 999999); - const date = DateTime.now().minus({ days: 2, months: i }).toISO(); - const subtotal = randomNumber(25, 949); - const tax = randomNumber(5, 50); - - return invoiceFactory.build({ - date, - id, - label: `Invoice #${id}`, - subtotal, - tax, - total: subtotal + tax, - }); - } - ); + const invoiceMocks = buildArray(10, (i: number): Invoice => { + const id = randomNumber(1, 999999); + const date = DateTime.now().minus({ days: 2, months: i }).toISO(); + const subtotal = randomNumber(25, 949); + const tax = randomNumber(5, 50); + + return invoiceFactory.build({ + date, + id, + label: `Invoice #${id}`, + subtotal, + tax, + total: subtotal + tax, + }); + }); const paymentMocks = invoiceMocks.map( (invoice: Invoice, i: number): Payment => { diff --git a/packages/manager/cypress/e2e/core/cloudpulse/alert-errors.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/alert-errors.spec.ts index 0163f19851c..328620c8cf5 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/alert-errors.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/alert-errors.spec.ts @@ -85,6 +85,7 @@ describe('Alerts Listing Page - Error Handling', () => { }); ui.actionMenuItem.findByTitle(action).should('be.visible').click(); + ui.button.findByTitle(action).should('be.visible').click(); cy.wait(alias).then(({ response }) => { ui.toast.assertMessage(response?.body.errors[0].reason); }); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/alert-show-details.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/alert-show-details.spec.ts index 5dfa6cbc1c5..66ce078ffe3 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/alert-show-details.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/alert-show-details.spec.ts @@ -132,6 +132,7 @@ describe('Integration Tests for Alert Show Detail Page', () => { }); it('should correctly display the details of the DBaaS alert in the alert details view', () => { + const searchPlaceholder = 'Search for a Region or Entity'; cy.visitWithLogin(`/alerts/definitions/detail/${service_type}/${id}`); cy.wait(['@getDBaaSAlertDefinitions', '@getMockedDbaasDatabases']); @@ -265,19 +266,17 @@ describe('Integration Tests for Alert Show Detail Page', () => { // Validate the Resources section (Resource and Region columns) cy.get('[data-qa-section="Resources"]').within(() => { ui.heading - .findByText('resource') + .findByText('entity') .scrollIntoView() .should('be.visible') - .should('have.text', 'Resource'); + .should('have.text', 'Entity'); ui.heading .findByText('region') .should('be.visible') .should('have.text', 'Region'); - cy.findByPlaceholderText('Search for a Region or Resource').should( - 'be.visible' - ); + cy.findByPlaceholderText(searchPlaceholder).should('be.visible'); cy.findByPlaceholderText('Select Regions').should('be.visible'); @@ -295,7 +294,7 @@ describe('Integration Tests for Alert Show Detail Page', () => { const regionLabel = regionMap.get(db.region) || 'Unknown Region'; cy.wrap(row).within(() => { - cy.get(`[data-qa-alert-cell="${rowNumber}_resource"]`).should( + cy.get(`[data-qa-alert-cell="${rowNumber}_entity"]`).should( 'have.text', db.label ); @@ -307,11 +306,11 @@ describe('Integration Tests for Alert Show Detail Page', () => { }); }); - // Sorting by Resource and Region columns - ui.heading.findByText('resource').should('be.visible').click(); + // Sorting by entity and Region columns + ui.heading.findByText('entity').should('be.visible').click(); verifyRowOrder(['4', '3', '2', '1']); - ui.heading.findByText('resource').should('be.visible').click(); + ui.heading.findByText('entity').should('be.visible').click(); verifyRowOrder(['1', '2', '3', '4']); ui.heading.findByText('region').should('be.visible').click(); @@ -320,8 +319,8 @@ describe('Integration Tests for Alert Show Detail Page', () => { ui.heading.findByText('region').should('be.visible').click(); verifyRowOrder(['1', '3', '2', '4']); - // Search by Resource - cy.findByPlaceholderText('Search for a Region or Resource') + // Search by Entity + cy.findByPlaceholderText(searchPlaceholder) .should('be.visible') .type(databases[0].label); @@ -335,7 +334,7 @@ describe('Integration Tests for Alert Show Detail Page', () => { ); // Search by region - cy.findByPlaceholderText('Search for a Region or Resource').clear(); + cy.findByPlaceholderText(searchPlaceholder).clear(); ui.regionSelect.find().click().type(`${regions[0].label}{enter}`); ui.regionSelect.find().click(); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts index 90d9498dd7b..3b754ca5e5b 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts @@ -64,6 +64,17 @@ const mockAlerts = [ }), ]; +interface AlertActionOptions { + action: 'Disable' | 'Enable'; + alertName: string; + alias: string; +} + +interface AlertToggleOptions extends AlertActionOptions { + confirmationText: string; + successMessage: string; +} + /** * @description * This code validates the presence and correct text of the table headers @@ -305,12 +316,13 @@ describe('Integration Tests for CloudPulse Alerts Listing Page', () => { }; // Function to toggle an alert's status - const toggleAlertStatus = ( - alertName: string, - action: 'Disable' | 'Enable', - alias: string, - successMessage: string - ) => { + const toggleAlertStatus = ({ + action, + alertName, + alias, + confirmationText, + successMessage, + }: AlertToggleOptions) => { cy.findByText(alertName) .should('be.visible') .closest('tr') @@ -320,29 +332,48 @@ describe('Integration Tests for CloudPulse Alerts Listing Page', () => { .should('be.visible') .click(); }); - ui.actionMenuItem.findByTitle(action).should('be.visible').click(); - cy.wait(alias).then(({ response }) => { + // verify dialog title + ui.dialog + .findByTitle(`${action} ${alertName} Alert?`) + .should('be.visible') + .within(() => { + cy.findByText(confirmationText).should('be.visible'); + ui.button + .findByTitle(action) + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait(alias).then(() => { ui.toast.assertMessage(successMessage); }); }; // Disable "Alert-1" - searchAlert('Alert-1'); - toggleAlertStatus( - 'Alert-1', - 'Disable', - '@getFirstAlertDefinitions', - UPDATE_ALERT_SUCCESS_MESSAGE - ); + const actions: Array = [ + { + action: 'Disable', + alertName: 'Alert-1', + alias: '@getFirstAlertDefinitions', + }, + { + action: 'Enable', + alertName: 'Alert-2', + alias: '@getSecondAlertDefinitions', + }, + ]; - // Enable "Alert-2" - searchAlert('Alert-2'); - toggleAlertStatus( - 'Alert-2', - 'Enable', - '@getSecondAlertDefinitions', - UPDATE_ALERT_SUCCESS_MESSAGE - ); + actions.forEach(({ action, alertName, alias }) => { + searchAlert(alertName); + toggleAlertStatus({ + action, + alertName, + alias, + confirmationText: `Are you sure you want to ${action.toLowerCase()} this alert definition?`, + successMessage: UPDATE_ALERT_SUCCESS_MESSAGE, + }); + }); }); }); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts index 6545e59d9c5..591e0d38016 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts @@ -165,7 +165,7 @@ describe('Create Alert', () => { mockGetDatabases(databaseMock); mockGetAllAlertDefinitions([mockAlerts]).as('getAlertDefinitionsList'); mockGetAlertChannels([notificationChannels]); - mockCreateAlertDefinition(serviceType, customAlertDefinition).as( + mockCreateAlertDefinition(serviceType, mockAlerts).as( 'createAlertDefinition' ); }); @@ -209,20 +209,20 @@ describe('Create Alert', () => { ui.autocomplete.findByLabel('Severity').should('be.visible').type('Severe'); ui.autocompletePopper.findByTitle('Severe').should('be.visible').click(); - // Search for Resource - cy.findByPlaceholderText('Search for a Region or Resource') + // Search for Entity + cy.findByPlaceholderText('Search for a Region or Entity') .should('be.visible') .type('database-2'); - // Find the table and locate the resource cell containing 'database-2', then check the corresponding checkbox + // Find the table and locate the entity cell containing 'database-2', then check the corresponding checkbox cy.get('[data-qa-alert-table="true"]') // Find the table - .contains('[data-qa-alert-cell*="resource"]', 'database-2') // Find resource cell + .contains('[data-qa-alert-cell*="entity"]', 'database-2') // Find entity cell .parents('tr') .find('[type="checkbox"]') .check(); - // Assert resource selection notice - cy.findByText('1 of 10 resources are selected.'); + // Assert entity selection notice + cy.findByText('1 of 10 entities are selected.'); // Fill metric details for the first rule const cpuUsageMetricDetails = { diff --git a/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts index de0def5b4b4..ab73676850a 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts @@ -113,14 +113,14 @@ describe('Integration Tests for Edit Alert', () => { cy.wait(['@getAlertDefinitions', '@getDatabases']); - // Verify that the heading with text 'resource' is visible - ui.heading.findByText('resource').should('be.visible'); + // Verify that the heading with text 'entity' is visible + ui.heading.findByText('entity').should('be.visible'); // Verify that the heading with text 'region' is visible ui.heading.findByText('region').should('be.visible'); - // Verify the initial selection of resources, then select all resources. - cy.findByText('3 of 50 resources are selected.') + // Verify the initial selection of entities, then select all entities. + cy.findByText('3 of 50 entities are selected.') .should('be.visible') .closest('[data-qa-notice]') .within(() => { @@ -133,7 +133,7 @@ describe('Integration Tests for Edit Alert', () => { }); // Confirm notice text updates to reflect selection. - cy.findByText('50 of 50 resources are selected.').should('be.visible'); + cy.findByText('50 of 50 entities are selected.').should('be.visible'); // Verify the initial state of the page size ui.pagination.findPageSizeSelect().click(); @@ -271,7 +271,7 @@ describe('Integration Tests for Edit Alert', () => { cy.url().should('endWith', '/alerts/definitions'); // Confirm toast notification appears - ui.toast.assertMessage('Alert resources successfully updated.'); + ui.toast.assertMessage('Alert entities successfully updated.'); }); }); }); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts index bb695e4f2df..190b3c1c094 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts @@ -30,7 +30,6 @@ import { ui } from 'support/ui'; import { accountFactory, - alertDefinitionFactory, alertFactory, cpuRulesFactory, dashboardMetricFactory, @@ -49,20 +48,6 @@ import type { Flags } from 'src/featureFlags'; const flags: Partial = { aclp: { beta: true, enabled: true } }; const mockAccount = accountFactory.build(); -// Mock alert definition -const customAlertDefinition = alertDefinitionFactory.build({ - channel_ids: [1], - description: 'update-description', - entity_ids: ['1', '2', '3', '4', '5'], - label: 'Alert-1', - rule_criteria: { - rules: [cpuRulesFactory.build(), memoryRulesFactory.build()], - }, - severity: 0, - tags: [''], - trigger_conditions: triggerConditionFactory.build(), -}); - // Mock alert details const alertDetails = alertFactory.build({ alert_channels: [{ id: 1 }], @@ -150,7 +135,7 @@ describe('Integration Tests for Edit Alert', () => { mockUpdateAlertDefinitions(service_type, id, alertDetails).as( 'updateDefinitions' ); - mockCreateAlertDefinition(service_type, customAlertDefinition).as( + mockCreateAlertDefinition(service_type, alertDetails).as( 'createAlertDefinition' ); mockGetCloudPulseMetricDefinitions(service_type, metricDefinitions); @@ -202,17 +187,17 @@ describe('Integration Tests for Edit Alert', () => { .should('have.value', 'Databases'); cy.findByLabelText('Severity').should('have.value', 'Severe'); - // Verify alert resource selection + // Verify alert entity selection cy.get('[data-qa-alert-table="true"]') - .contains('[data-qa-alert-cell*="resource"]', 'database-3') + .contains('[data-qa-alert-cell*="entity"]', 'database-3') .parents('tr') .find('[type="checkbox"]') .should('be.checked'); - // Verify alert resource selection count message + // Verify alert entity selection count message cy.get('[data-testid="selection_notice"]').should( 'contain', - '1 of 5 resources are selected.' + '1 of 5 entities are selected.' ); // Assert rule values 1 @@ -289,6 +274,10 @@ describe('Integration Tests for Edit Alert', () => { ui.autocomplete.findByLabel('Severity').clear(); ui.autocomplete.findByLabel('Severity').type('Info'); ui.autocompletePopper.findByTitle('Info').should('be.visible').click(); + cy.get('[data-qa-notice="true"]') + .find('button') + .contains('Deselect All') + .click(); cy.get('[data-qa-notice="true"]') .find('button') .contains('Select All') diff --git a/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts index 17d5a0d5f84..af475bfb926 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts @@ -1,7 +1,7 @@ /** * @file Integration Tests for CloudPulse Custom and Preset Verification */ -import { regionFactory } from '@linode/utilities'; +import { profileFactory, regionFactory } from '@linode/utilities'; import { DateTime } from 'luxon'; import { widgetDetails } from 'support/constants/widgets'; import { mockGetAccount } from 'support/intercepts/account'; @@ -29,7 +29,6 @@ import { dashboardFactory, dashboardMetricFactory, databaseFactory, - profileFactory, widgetFactory, } from 'src/factories'; import { convertToGmt } from 'src/features/CloudPulse/Utils/CloudPulseDateTimePickerUtils'; diff --git a/packages/manager/cypress/e2e/core/databases/create-database.spec.ts b/packages/manager/cypress/e2e/core/databases/create-database.spec.ts index d053ff241fb..f2f55993e8f 100644 --- a/packages/manager/cypress/e2e/core/databases/create-database.spec.ts +++ b/packages/manager/cypress/e2e/core/databases/create-database.spec.ts @@ -1,3 +1,4 @@ +import { grantsFactory, profileFactory } from '@linode/utilities'; import { databaseConfigurations, mockDatabaseEngineTypes, @@ -7,8 +8,8 @@ import { mockGetAccount, mockGetUser } from 'support/intercepts/account'; import { mockCreateDatabase, mockGetDatabaseEngines, - mockGetDatabaseTypes, mockGetDatabases, + mockGetDatabaseTypes, } from 'support/intercepts/databases'; import { mockGetEvents } from 'support/intercepts/events'; import { @@ -24,16 +25,14 @@ import { accountUserFactory, databaseFactory, eventFactory, - grantsFactory, - profileFactory, } from 'src/factories'; import type { Database } from '@linode/api-v4'; -import type { databaseClusterConfiguration } from 'support/constants/databases'; +import type { DatabaseClusterConfiguration } from 'support/constants/databases'; describe('create a database cluster, mocked data', () => { databaseConfigurations.forEach( - (configuration: databaseClusterConfiguration) => { + (configuration: DatabaseClusterConfiguration) => { // @TODO Add assertions for DBaaS pricing. it(`creates a ${configuration.linodeType} ${configuration.engine} v${configuration.version}.x ${configuration.clusterSize}-node cluster`, () => { // Database mock immediately after instance has been created. @@ -74,13 +73,59 @@ describe('create a database cluster, mocked data', () => { }); const clusterSizeSelection = - configuration.clusterSize > 1 ? '3 Nodes' : '1 Node'; + configuration.clusterSize == 1 + ? '1 Node' + : configuration.clusterSize == 2 + ? '2 Nodes' + : '3 Nodes'; + + const nodes = + configuration.clusterSize == 1 + ? 'Primary (1 Node)' + : configuration.clusterSize == 2 + ? 'Primary (+1 Node)' + : 'Primary (+2 Nodes)'; const clusterCpuType = configuration.linodeType.indexOf('-dedicated-') !== -1 ? 'Dedicated CPU' : 'Shared CPU'; + // Function to validate Action Menu on the landing page as per db cluster status + const validateActionItems = (state: string) => { + const menuStates: Record> = { + active: { + Delete: true, + 'Manage Access Controls': true, + 'Reset Root Password': true, + Resize: true, + Resume: false, + Suspend: true, + }, + provisioning: { + Delete: true, + 'Manage Access Controls': true, + 'Reset Root Password': true, + Resize: true, + Resume: false, + Suspend: false, + }, + }; + const expectedItems = menuStates[state]; + ui.actionMenu + .findByTitle(`Action menu for Database ${databaseMock.label}`) + .should('be.visible') + .click(); + + Object.entries(expectedItems).forEach(([label, enabled]) => { + ui.actionMenuItem + .findByTitle(label) + .should('be.visible') + .should(enabled ? 'be.enabled' : 'be.disabled'); + }); + cy.get('body').click(0, 0); + }; + // Mock account to ensure 'Managed Databases' capability. mockGetAccount(accountFactory.build()).as('getAccount'); mockGetDatabaseEngines(mockDatabaseEngineTypes).as( @@ -120,6 +165,34 @@ describe('create a database cluster, mocked data', () => { // Database cluster size selection. cy.contains(clusterSizeSelection).should('be.visible').click(); + if (clusterCpuType == 'Shared CPU') { + cy.findByLabelText('2 Nodes - High Availability').should('not.exist'); + } + + // Manage Access while creating a cluster for ipv4 and ipv6 + if (configuration.ip) { + cy.findByText('Specific Access (recommended)') + .should('be.visible') + .click(); + cy.get('[id="domain-transfer-ip-0"]').should('be.visible').click(); + cy.focused().type(configuration.ip); + cy.findByText('Add an IP').should('be.visible').click(); + } + + if (!configuration.ip) { + cy.findByText('No Access (Deny connections from all IP addresses)') + .should('be.visible') + .click(); + cy.get('[id="domain-transfer-ip-0"]') + .should('be.visible') + .should('be.disabled'); + cy.findByText('Add an IP').should('be.visible').should('be.disabled'); + } + + // Summary section, TODO validating plan details. + cy.findByText('Summary').should('be.visible'); + cy.findAllByTestId('currentSummary').should('be.visible'); + // Create database, confirm redirect, and that new instance is listed. cy.findByText('Create Database Cluster').should('be.visible').click(); cy.wait('@createDatabase'); @@ -130,8 +203,24 @@ describe('create a database cluster, mocked data', () => { `/databases/${databaseMock.engine}/${databaseMock.id}` ); - cy.findByText(databaseMock.label).should('be.visible'); - cy.findByText(databaseRegionLabel).should('be.visible'); + // Validate Cluster Configuration on Summary page + [ + 'Status', + 'Plan', + 'Nodes', + 'CPUs', + 'Engine', + 'Region', + 'RAM', + 'Total Disk Size', + databaseMock.label, + databaseRegionLabel, + `${configuration.engine} v${configuration.version}`, + nodes, + `${databaseMock.total_disk_size_gb} GB`, + ].forEach((text: string) => { + cy.findByText(text).should('be.visible'); + }); // Navigate back to landing page. ui.entityHeader.find().within(() => { @@ -154,6 +243,9 @@ describe('create a database cluster, mocked data', () => { }).should('be.visible'); }); + // Confirm enabled dropdown option when cluster is in provisioning state + validateActionItems('provisioning'); + // Mock next request to fetch databases so that instance appears active. // Mock next event request to trigger Cloud to re-fetch DBaaS instances. mockGetDatabases([databaseMockActive]).as('getDatabases'); @@ -166,6 +258,9 @@ describe('create a database cluster, mocked data', () => { .within(() => { cy.findByText('Active').should('be.visible'); }); + + // Confirm enabled dropdown options when cluster is in active state + validateActionItems('active'); }); } ); diff --git a/packages/manager/cypress/e2e/core/databases/delete-database.spec.ts b/packages/manager/cypress/e2e/core/databases/delete-database.spec.ts index 9d6565873c4..a80b2d93b4e 100644 --- a/packages/manager/cypress/e2e/core/databases/delete-database.spec.ts +++ b/packages/manager/cypress/e2e/core/databases/delete-database.spec.ts @@ -18,11 +18,11 @@ import { randomIp, randomNumber } from 'support/util/random'; import { accountFactory, databaseFactory } from 'src/factories'; -import type { databaseClusterConfiguration } from 'support/constants/databases'; +import type { DatabaseClusterConfiguration } from 'support/constants/databases'; describe('Delete database clusters', () => { databaseConfigurations.forEach( - (configuration: databaseClusterConfiguration) => { + (configuration: DatabaseClusterConfiguration) => { describe(`Deletes a ${configuration.linodeType} ${configuration.engine} v${configuration.version}.x ${configuration.clusterSize}-node cluster`, () => { /* * - Tests database deletion UI flow using mocked data. diff --git a/packages/manager/cypress/e2e/core/databases/resize-database.spec.ts b/packages/manager/cypress/e2e/core/databases/resize-database.spec.ts index d3222977fb0..2680ac03fa9 100644 --- a/packages/manager/cypress/e2e/core/databases/resize-database.spec.ts +++ b/packages/manager/cypress/e2e/core/databases/resize-database.spec.ts @@ -20,7 +20,7 @@ import { randomIp, randomNumber, randomString } from 'support/util/random'; import { databaseFactory, possibleStatuses } from 'src/factories/databases'; -import type { databaseClusterConfiguration } from 'support/constants/databases'; +import type { DatabaseClusterConfiguration } from 'support/constants/databases'; /** * Resizes a current database cluster to a larger plan size. @@ -52,7 +52,7 @@ const resizeDatabase = (initialLabel: string) => { describe('Resizing existing clusters', () => { databaseConfigurationsResize.forEach( - (configuration: databaseClusterConfiguration) => { + (configuration: DatabaseClusterConfiguration) => { describe(`Resizes a ${configuration.linodeType} ${configuration.engine} v${configuration.version}.x ${configuration.clusterSize}-node cluster (legacy DBaaS)`, () => { /* * - Tests active database resize UI flows using mocked data. diff --git a/packages/manager/cypress/e2e/core/databases/update-database.spec.ts b/packages/manager/cypress/e2e/core/databases/update-database.spec.ts index 1ed3176d81b..6a14ee4dc9d 100644 --- a/packages/manager/cypress/e2e/core/databases/update-database.spec.ts +++ b/packages/manager/cypress/e2e/core/databases/update-database.spec.ts @@ -27,7 +27,7 @@ import { import { databaseFactory } from 'src/factories/databases'; -import type { databaseClusterConfiguration } from 'support/constants/databases'; +import type { DatabaseClusterConfiguration } from 'support/constants/databases'; /** * Updates a database cluster's label. @@ -183,7 +183,7 @@ const modifyMaintenanceWindow = (label: string, windowValue: string) => { describe('Update database clusters', () => { databaseConfigurations.forEach( - (configuration: databaseClusterConfiguration) => { + (configuration: DatabaseClusterConfiguration) => { describe(`updates a ${configuration.linodeType} ${configuration.engine} v${configuration.version}.x ${configuration.clusterSize}-node cluster`, () => { /* * - Tests active database update UI flows using mocked data. @@ -332,7 +332,8 @@ describe('Update database clusters', () => { const errorMessage = 'Your database is provisioning; please wait until provisioning is complete to perform this operation.'; - const hostnameRegex = /your hostnames? will appear here once (it is|they are) available./i; + const hostnameRegex = + /your hostnames? will appear here once (it is|they are) available./i; mockGetAccount(accountFactory.build()).as('getAccount'); mockGetDatabase(database).as('getDatabase'); diff --git a/packages/manager/cypress/e2e/core/firewalls/create-firewall-custom.spec.ts b/packages/manager/cypress/e2e/core/firewalls/create-firewall-custom.spec.ts new file mode 100644 index 00000000000..d0a7e8ba4e1 --- /dev/null +++ b/packages/manager/cypress/e2e/core/firewalls/create-firewall-custom.spec.ts @@ -0,0 +1,428 @@ +/** + * @file Integration tests for Firewall creation flows involving custom rules. + */ + +import { linodeFactory, nodeBalancerFactory } from '@linode/utilities'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { + mockCreateFirewall, + mockGetFirewalls, +} from 'support/intercepts/firewalls'; +import { mockGetLinodes } from 'support/intercepts/linodes'; +import { mockGetNodeBalancers } from 'support/intercepts/nodebalancers'; +import { ui } from 'support/ui'; +import { randomLabel } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; + +import { accountFactory, firewallFactory } from 'src/factories'; + +describe('Can create Firewalls using custom rules', () => { + beforeEach(() => { + // TODO M3-9775 - Remove mock once `linodeInterfaces` feature flag is removed. + mockAppendFeatureFlags({ + linodeInterfaces: { + enabled: true, + }, + }); + // TODO M3-9775 - Remove mock once all accounts get 'Linode Interfaces' capability. + mockGetAccount( + accountFactory.build({ + capabilities: [ + 'Cloud Firewall', + 'Linode Interfaces', + 'Linodes', + 'NodeBalancers', + ], + }) + ); + }); + + /* + * - Confirms users can create Firewalls using custom rules. + * - Confirms Firewall can be created with default inbound/outbound policies. + * - Confirms Firewall can be created with no Linodes or NodeBalancers attached. + * - Confirms outgoing Firewall create request reflects the user's selected options. + * - Confirms landing page automatically updates to show the new Firewall. + */ + it('can create a Firewall using custom rules', () => { + const mockFirewall = firewallFactory.build({ + label: randomLabel(), + rules: { + inbound: [], + inbound_policy: 'DROP', + outbound: [], + outbound_policy: 'ACCEPT', + }, + entities: [], + }); + + mockGetFirewalls([]); + mockCreateFirewall(mockFirewall).as('createFirewall'); + + cy.visitWithLogin('/firewalls'); + + ui.button + .findByTitle('Create Firewall') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.drawer + .findByTitle('Create Firewall') + .should('be.visible') + .within(() => { + cy.findByLabelText('Custom Firewall').should('be.checked'); + cy.findByText('Label').should('be.visible').type(mockFirewall.label); + + // Make no changes to the default selections, just click "Create Firewall". + mockGetFirewalls([mockFirewall]); + ui.buttonGroup + .findButtonByTitle('Create Firewall') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm that outgoing API request contains expected payload. Specifically: + // + // - No Linodes or NodeBalancers assigned. + // - Default inbound policy is DROP. + // - Default outbound policy is ACCEPT. + // - No other inbound or outbound rules specified. + cy.wait('@createFirewall').then((xhr) => { + expect(xhr.request.body.devices?.linodes).to.be.empty; + expect(xhr.request.body.devices?.nodebalancers).to.be.empty; + expect(xhr.request.body.rules?.inbound_policy).to.equal('DROP'); + expect(xhr.request.body.rules?.outbound_policy).to.equal('ACCEPT'); + expect(xhr.request.body.rules?.inbound).to.be.undefined; + expect(xhr.request.body.rules?.outbound).to.be.undefined; + }); + + ui.toast.assertMessage( + `Firewall ${mockFirewall.label} successfully created` + ); + + // Confirm that landing page automatically updates to list the new Firewall, + // and the expected information is displayed. + cy.findByText(mockFirewall.label) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('Enabled').should('be.visible'); + cy.findByText('No rules').should('be.visible'); + cy.findByText('None assigned').should('be.visible'); + }); + }); + + /* + * - Confirms users can create Firewalls using custom rules. + * - Confirms users can change inbound and outbound policy. + * - Confirms Firewall can be created with a Linode attached. + * - Confirms outgoing Firewall create request reflects the user's selected options. + * - Confirms landing page automatically updates to show the new Firewall. + */ + it('can create a Firewall using custom rules and assign Linodes', () => { + const mockLinode = linodeFactory.build({ + label: randomLabel(), + region: chooseRegion().id, + }); + + const mockFirewall = firewallFactory.build({ + label: randomLabel(), + rules: { + inbound_policy: 'ACCEPT', + inbound: undefined, + outbound_policy: 'DROP', + outbound: undefined, + }, + entities: [ + { + id: mockLinode.id, + type: 'linode', + label: mockLinode.label, + url: `/linodes/${mockLinode.id}`, + }, + ], + }); + + mockGetFirewalls([]); + mockGetLinodes([mockLinode]); + mockCreateFirewall(mockFirewall).as('createFirewall'); + + cy.visitWithLogin('/firewalls'); + + ui.button + .findByTitle('Create Firewall') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.drawer + .findByTitle('Create Firewall') + .should('be.visible') + .within(() => { + cy.findByLabelText('Custom Firewall').should('be.checked'); + cy.findByText('Label').should('be.visible').type(mockFirewall.label); + + // Swap default inbound/outbound policies to later confirm that they + // are included in the outgoing API request. + cy.findByLabelText('default inbound policy').within(() => { + cy.findByText('Accept').should('be.visible').click(); + cy.findByLabelText('Accept').should('be.checked'); + cy.findByLabelText('Drop').should('not.be.checked'); + }); + + cy.findByLabelText('default outbound policy').within(() => { + cy.findByText('Drop').should('be.visible').click(); + cy.findByLabelText('Drop').should('be.checked'); + cy.findByLabelText('Accept').should('not.be.checked'); + }); + + // Assign a Linode + cy.findByText('Linodes').should('be.visible').click(); + + cy.focused().type(mockLinode.label); + ui.autocompletePopper.findByTitle(mockLinode.label).click(); + cy.focused().type('{esc}'); + + mockGetFirewalls([mockFirewall]); + ui.buttonGroup + .findButtonByTitle('Create Firewall') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm outgoing request payload contains chosen Linode, and reflects + // default inbound/outbound policy choice. + cy.wait('@createFirewall').then((xhr) => { + expect(xhr.request.body.devices?.linodes).to.deep.equal([mockLinode.id]); + expect(xhr.request.body.devices?.nodebalancers).to.be.empty; + expect(xhr.request.body.rules?.inbound_policy).to.equal('ACCEPT'); + expect(xhr.request.body.rules?.outbound_policy).to.equal('DROP'); + expect(xhr.request.body.rules?.inbound).to.be.undefined; + expect(xhr.request.body.rules?.outbound).to.be.undefined; + }); + + ui.toast.assertMessage( + `Firewall ${mockFirewall.label} successfully created` + ); + + cy.findByText(mockFirewall.label) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('Enabled').should('be.visible'); + cy.findByText('No rules').should('be.visible'); + + // Confirm that the attached Linode is shown in the table row. + cy.findByText(mockLinode.label).should('be.visible'); + }); + }); + + /* + * - Confirms users can create Firewalls using custom rules. + * - Confirms Firewall can be created with default inbound/outbound policies. + * - Confirms Firewall can be created with a NodeBalancer attached. + * - Confirms outgoing Firewall create request reflects the user's selected options. + * - Confirms landing page automatically updates to show the new Firewall. + */ + it('can create a Firewall using custom rules and assign NodeBalancers', () => { + const mockNodeBalancer = nodeBalancerFactory.build({ + label: randomLabel(), + region: chooseRegion().id, + }); + + const mockFirewall = firewallFactory.build({ + label: randomLabel(), + rules: { + inbound_policy: 'DROP', + inbound: undefined, + outbound_policy: 'ACCEPT', + outbound: undefined, + }, + entities: [ + { + id: mockNodeBalancer.id, + type: 'nodebalancer', + label: mockNodeBalancer.label, + url: `/nodebalancers/${mockNodeBalancer.id}/summary`, + }, + ], + }); + + mockGetFirewalls([]); + mockGetNodeBalancers([mockNodeBalancer]); + mockCreateFirewall(mockFirewall).as('createFirewall'); + + cy.visitWithLogin('/firewalls'); + + ui.button + .findByTitle('Create Firewall') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.drawer + .findByTitle('Create Firewall') + .should('be.visible') + .within(() => { + cy.findByLabelText('Custom Firewall').should('be.checked'); + cy.findByText('Label').should('be.visible').type(mockFirewall.label); + + // Leave inbound and outbound defaults as-is. + // Assign a NodeBalancer. + cy.findByText('NodeBalancers').should('be.visible').click(); + + cy.focused().type(mockNodeBalancer.label); + ui.autocompletePopper.findByTitle(mockNodeBalancer.label).click(); + cy.focused().type('{esc}'); + + // Create the Firewall. + mockGetFirewalls([mockFirewall]); + ui.buttonGroup + .findButtonByTitle('Create Firewall') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm outgoing request payload contains chosen NodeBalancer. + cy.wait('@createFirewall').then((xhr) => { + expect(xhr.request.body.devices?.linodes).to.be.empty; + expect(xhr.request.body.devices?.nodebalancers).to.deep.equal([ + mockNodeBalancer.id, + ]); + expect(xhr.request.body.rules?.inbound_policy).to.equal('DROP'); + expect(xhr.request.body.rules?.outbound_policy).to.equal('ACCEPT'); + expect(xhr.request.body.rules?.inbound).to.be.undefined; + expect(xhr.request.body.rules?.outbound).to.be.undefined; + }); + + ui.toast.assertMessage( + `Firewall ${mockFirewall.label} successfully created` + ); + + cy.findByText(mockFirewall.label) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('Enabled').should('be.visible'); + cy.findByText('No rules').should('be.visible'); + cy.findByText(mockNodeBalancer.label).should('be.visible'); + }); + }); + + /* + * - Confirms users can create Firewalls using custom rules. + * - Confirms Firewall can be created with default inbound/outbound policies. + * - Confirms Firewall can be created with a Linode and a NodeBalancer attached. + * - Confirms outgoing Firewall create request reflects the user's selected options. + * - Confirms landing page automatically updates to show the new Firewall. + */ + it('can create a Firewall using custom rules, and assign Linodes and NodeBalancers', () => { + const mockLinode = linodeFactory.build({ + label: randomLabel(), + region: chooseRegion().id, + }); + + const mockNodeBalancer = nodeBalancerFactory.build({ + label: randomLabel(), + region: chooseRegion().id, + }); + + const mockFirewall = firewallFactory.build({ + label: randomLabel(), + rules: { + inbound_policy: 'DROP', + inbound: undefined, + outbound_policy: 'ACCEPT', + outbound: undefined, + }, + entities: [ + { + id: mockLinode.id, + type: 'linode', + label: mockLinode.label, + url: `/linodes/${mockLinode.id}`, + }, + { + id: mockNodeBalancer.id, + type: 'nodebalancer', + label: mockNodeBalancer.label, + url: `/nodebalancers/${mockNodeBalancer.id}/summary`, + }, + ], + }); + + mockGetFirewalls([]); + mockGetLinodes([mockLinode]); + mockGetNodeBalancers([mockNodeBalancer]); + mockCreateFirewall(mockFirewall).as('createFirewall'); + + cy.visitWithLogin('/firewalls'); + + ui.button + .findByTitle('Create Firewall') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.drawer + .findByTitle('Create Firewall') + .should('be.visible') + .within(() => { + cy.findByLabelText('Custom Firewall').should('be.checked'); + cy.findByText('Label').should('be.visible').type(mockFirewall.label); + + // Leave inbound and outbound defaults as-is. + // Assign a Linode and a NodeBalancer. + cy.findByText('Linodes').should('be.visible').click(); + + cy.focused().type(mockLinode.label); + ui.autocompletePopper.findByTitle(mockLinode.label).click(); + cy.focused().type('{esc}'); + + cy.findByText('NodeBalancers').should('be.visible').click(); + + cy.focused().type(mockNodeBalancer.label); + ui.autocompletePopper.findByTitle(mockNodeBalancer.label).click(); + cy.focused().type('{esc}'); + + // Create the Firewall. + mockGetFirewalls([mockFirewall]); + ui.buttonGroup + .findButtonByTitle('Create Firewall') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm outgoing request payload contains chosen Linode and NodeBalancer. + cy.wait('@createFirewall').then((xhr) => { + expect(xhr.request.body.devices?.linodes).to.deep.equal([mockLinode.id]); + expect(xhr.request.body.devices?.nodebalancers).to.deep.equal([ + mockNodeBalancer.id, + ]); + expect(xhr.request.body.rules?.inbound_policy).to.equal('DROP'); + expect(xhr.request.body.rules?.outbound_policy).to.equal('ACCEPT'); + expect(xhr.request.body.rules?.inbound).to.be.undefined; + expect(xhr.request.body.rules?.outbound).to.be.undefined; + }); + + ui.toast.assertMessage( + `Firewall ${mockFirewall.label} successfully created` + ); + + cy.findByText(mockFirewall.label) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('Enabled').should('be.visible'); + cy.findByText('No rules').should('be.visible'); + cy.contains(mockLinode.label).should('be.visible'); + cy.contains(mockNodeBalancer.label).should('be.visible'); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/firewalls/create-firewall-with-template.spec.ts b/packages/manager/cypress/e2e/core/firewalls/create-firewall-with-template.spec.ts new file mode 100644 index 00000000000..866f03e5dbd --- /dev/null +++ b/packages/manager/cypress/e2e/core/firewalls/create-firewall-with-template.spec.ts @@ -0,0 +1,367 @@ +/** + * @file Integration tests for Firewall creation flows involving templates. + */ + +import { + accountFactory, + firewallFactory, + firewallTemplateFactory, +} from 'src/factories'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { + mockCreateFirewall, + mockGetFirewalls, + mockGetFirewallTemplate, + mockGetFirewallTemplates, +} from 'support/intercepts/firewalls'; +import { mockApiInternalUser } from 'support/intercepts/general'; +import { ui } from 'support/ui'; +import { buildArray } from 'support/util/arrays'; +import { randomNumber } from 'support/util/random'; + +const mockFirewallTemplateVpc = firewallTemplateFactory.build({ + slug: 'vpc', +}); + +const mockFirewallTemplatePublic = firewallTemplateFactory.build({ + slug: 'public', +}); + +const mockFirewallTemplateInternalUser = firewallTemplateFactory.build({ + slug: 'akamai-non-prod', +}); + +describe('Can create Firewalls using templates', () => { + beforeEach(() => { + // TODO M3-9775 - Remove mock once `linodeInterfaces` feature flag is removed. + mockAppendFeatureFlags({ + linodeInterfaces: { + enabled: true, + }, + }); + // TODO Remove mock once all accounts get 'Linode Interfaces' capability. + mockGetAccount( + accountFactory.build({ + capabilities: ['Cloud Firewall', 'Linode Interfaces', 'Linodes'], + }) + ); + }); + + /* + * - Confirms that users can create a Firewall using the VPC template. + * - Confirms that VPC template-specific details are shown prior to creating Firewall. + * - Confirms that outgoing Firewall create request includes autogenerated label. + * - Confirms that outgoing Firewall create request includes chosen rules. + * - Confirms that landing page automatically updates to display the new Firewall. + */ + it('can create a Firewall using VPC template', () => { + const mockFirewall = firewallFactory.build({ + label: 'vpc-1', + rules: mockFirewallTemplateVpc.rules, + entities: [], + }); + + mockGetFirewalls([]); + mockGetFirewallTemplates([ + mockFirewallTemplateVpc, + mockFirewallTemplatePublic, + ]); + mockGetFirewallTemplate(mockFirewallTemplateVpc); + mockCreateFirewall(mockFirewall).as('createFirewall'); + + cy.visitWithLogin('/firewalls'); + + ui.button + .findByTitle('Create Firewall') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.drawer + .findByTitle('Create Firewall') + .should('be.visible') + .within(() => { + cy.findByText('From a Template').should('be.visible').click(); + + cy.findByLabelText('Firewall Template').click(); + ui.autocompletePopper.find().within(() => { + cy.findByText('VPC Firewall Template').should('be.visible').click(); + }); + + // Confirm that selecting "VPC Firewall Template" shows descriptive + // information and rule details specific for VPC selection. + cy.contains('traffic from the VPC address space').should('be.visible'); + cy.contains('Allow traffic for RFC1918 ranges').should('be.visible'); + + // Create the Firewall. + mockGetFirewalls([mockFirewall]); + ui.buttonGroup + .findButtonByTitle('Create Firewall') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait('@createFirewall').then((xhr) => { + // Confirm that label is automatically assigned, and that rules reflect the selected template. + expect(xhr.request.body['label']).to.equal('vpc-1'); + expect(xhr.request.body['rules']).to.deep.equal( + mockFirewallTemplateVpc.rules + ); + }); + + // Confirm that page automatically updates to show the new Firewall, + // and that the expected information is displayed alongside the Firewall. + cy.findByText('vpc-1') + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('Enabled').should('be.visible'); + cy.findByText('1 Inbound / 1 Outbound').should('be.visible'); + cy.findByText('None assigned').should('be.visible'); + }); + }); + + /* + * - Confirms that users can create a Firewall using the Public template. + * - Confirms that Public template-specific details are shown prior to creating Firewall. + * - Confirms that outgoing Firewall create request includes autogenerated label. + * - Confirms that autogenerated Firewall label takes into account existing Firewalls. + * - Confirms that outgoing Firewall create request includes chosen rules. + * - Confirms that landing page automatically updates to display the new Firewall. + */ + it('can create a Firewall using Public template', () => { + const existingFirewallCount = randomNumber(1, 10); + + const mockExistingFirewalls = buildArray(existingFirewallCount, (i) => { + return firewallFactory.build({ + label: `public-${i + 1}`, + rules: mockFirewallTemplatePublic.rules, + entities: [], + }); + }); + + const mockNewFirewallLabel = `public-${existingFirewallCount + 1}`; + const mockNewFirewall = firewallFactory.build({ + label: mockNewFirewallLabel, + rules: mockFirewallTemplatePublic.rules, + entities: [], + }); + + mockGetFirewalls(mockExistingFirewalls).as('getFirewalls'); + mockGetFirewallTemplates([ + mockFirewallTemplateVpc, + mockFirewallTemplatePublic, + ]); + mockGetFirewallTemplate(mockFirewallTemplatePublic); + mockCreateFirewall(mockNewFirewall).as('createFirewall'); + + cy.visitWithLogin('/firewalls'); + cy.wait('@getFirewalls'); + + ui.button + .findByTitle('Create Firewall') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.drawer + .findByTitle('Create Firewall') + .should('be.visible') + .within(() => { + cy.findByText('From a Template').should('be.visible').click(); + + cy.findByLabelText('Firewall Template').click(); + ui.autocompletePopper.find().within(() => { + cy.findByText('Public Firewall Template') + .should('be.visible') + .click(); + }); + + // Confirm that selecting "Public Firewall Template" shows descriptive + // information and rule details specific to the public Firewall template. + cy.contains( + 'Allows for login with SSH, and regular networking control data.' + ).should('be.visible'); + + // Create the Firewall + mockGetFirewalls([...mockExistingFirewalls, mockNewFirewall]).as( + 'getFirewalls' + ); + ui.buttonGroup + .findButtonByTitle('Create Firewall') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait('@createFirewall').then((xhr) => { + // Confirm that label is automatically assigned, taking into account existing firewall labels, + // and that the Firewall rules reflect the selected template. + expect(xhr.request.body['label']).to.equal(mockNewFirewallLabel); + expect(xhr.request.body['rules']).to.deep.equal( + mockFirewallTemplatePublic.rules + ); + }); + + // Confirm that page automatically updates to show the new Firewall, + // and that the expected information is displayed alongside the Firewall. + cy.findByText(mockNewFirewallLabel) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('Enabled').should('be.visible'); + cy.findByText('1 Inbound / 1 Outbound').should('be.visible'); + cy.findByText('None assigned').should('be.visible'); + }); + }); + + /* + * - Confirms that users can create a Firewall using the internal template. + * - Confirms that internal choice is present when internal API header exists and API includes the template. + * - Confirms that outgoing Firewall create request includes autogenerated label. + * - Confirms that outgoing Firewall create request includes chosen rules. + * - Confirms that landing page automatically updates to display the new Firewall. + */ + it('can create a Firewall using internal-only template as internal user', () => { + const mockFirewall = firewallFactory.build({ + label: 'akamai-non-prod-1', + rules: mockFirewallTemplateInternalUser.rules, + entities: [], + }); + + mockGetFirewalls([]); + mockGetFirewallTemplates([ + mockFirewallTemplateInternalUser, + mockFirewallTemplateVpc, + mockFirewallTemplatePublic, + ]); + mockGetFirewallTemplate(mockFirewallTemplateInternalUser); + mockCreateFirewall(mockFirewall).as('createFirewall'); + mockApiInternalUser(); + + cy.visitWithLogin('/firewalls'); + + ui.button + .findByTitle('Create Firewall') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.drawer + .findByTitle('Create Firewall') + .should('be.visible') + .within(() => { + cy.findByText('From a Template').should('be.visible').click(); + + cy.findByLabelText('Firewall Template').click(); + ui.autocompletePopper.find().within(() => { + cy.findByText('Akamai Internal Firewall Template') + .should('be.visible') + .click(); + }); + + // No descriptive text appears when selecting internal template. + // Proceed with Firewall creation. + mockGetFirewalls([mockFirewall]); + ui.buttonGroup + .findButtonByTitle('Create Firewall') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait('@createFirewall').then((xhr) => { + // Confirm that page automatically updates to show the new Firewall, + // and that the expected information is displayed alongside the Firewall. + expect(xhr.request.body['label']).to.equal('akamai-non-prod-1'); + expect(xhr.request.body['rules']).to.deep.equal( + mockFirewallTemplateInternalUser.rules + ); + }); + + // Confirm that page automatically updates to show the new Firewall, + // and that the expected information is displayed alongside the Firewall. + cy.findByText('akamai-non-prod-1') + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('Enabled').should('be.visible'); + cy.findByText('1 Inbound / 1 Outbound').should('be.visible'); + cy.findByText('None assigned').should('be.visible'); + }); + }); + + /* + * - Confirms that the "Akamai Internal Firewall Template" Firewall template is absent for normal users. + * - Confirms absence when internal user API header is not present and `/firewalls/templates` API response omits template. + */ + it('cannot create a Firewall using internal-only template as normal user', () => { + mockGetFirewalls([]).as('getFirewalls'); + mockGetFirewallTemplates([ + mockFirewallTemplateVpc, + mockFirewallTemplatePublic, + ]); + + cy.visitWithLogin('/firewalls'); + + ui.button + .findByTitle('Create Firewall') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.drawer + .findByTitle('Create Firewall') + .should('be.visible') + .within(() => { + cy.findByText('From a Template').should('be.visible').click(); + + cy.findByLabelText('Firewall Template').click(); + ui.autocompletePopper.find().within(() => { + cy.findByText('VPC Firewall Template').should('be.visible'); + cy.findByText('Public Firewall Template').should('be.visible'); + cy.findByText('Akamai Internal Firewall Template').should( + 'not.exist' + ); + }); + }); + }); + + // TODO M3-9775 - Delete this test once `linodeInterfaces` feature flag is removed. + /* + * - Confirms that "Custom Firewall" and "From a Template" selections are absent when feature flag is disabled. + */ + it('does not show template selection when Linode Interfaces is disabled', () => { + mockAppendFeatureFlags({ + linodeInterfaces: { + enabled: false, + }, + }); + mockGetFirewalls([]); + + cy.visitWithLogin('/firewalls'); + + ui.button + .findByTitle('Create Firewall') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.drawer + .findByTitle('Create Firewall') + .should('be.visible') + .within(() => { + // Assert visibility of create button before confirming absence of + // template/custom radio buttons to eliminate the risk of false positives + // from the assertions succeeding before the drawer contents loads, etc. + ui.buttonGroup + .findButtonByTitle('Create Firewall') + .should('be.visible'); + + cy.findByText('From a Template').should('not.exist'); + cy.findByText('Custom Firewall').should('not.exist'); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/firewalls/create-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/create-firewall.spec.ts index 6e2956c4c98..43822b48fa3 100644 --- a/packages/manager/cypress/e2e/core/firewalls/create-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/create-firewall.spec.ts @@ -7,6 +7,8 @@ import { createTestLinode } from 'support/util/linodes'; import { randomLabel, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; authenticate(); +// Firewall GET API request performance issues need to be addressed in order to unskip this test +// See M3-9619 describe.skip('create firewall', () => { before(() => { cleanUp(['lke-clusters', 'linodes', 'firewalls']); diff --git a/packages/manager/cypress/e2e/core/firewalls/delete-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/delete-firewall.spec.ts index ce59d0b311c..f0037b28ed8 100644 --- a/packages/manager/cypress/e2e/core/firewalls/delete-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/delete-firewall.spec.ts @@ -1,18 +1,27 @@ import { createFirewall } from '@linode/api-v4'; import { authenticate } from 'support/api/authentication'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { + mockGetFirewalls, + mockGetFirewallSettings, +} from 'support/intercepts/firewalls'; import { ui } from 'support/ui'; -import { cleanUp } from 'support/util/cleanup'; import { randomLabel } from 'support/util/random'; -import { firewallFactory } from 'src/factories/firewalls'; +import { accountFactory, firewallFactory } from 'src/factories'; +import { DEFAULT_FIREWALL_TOOLTIP_TEXT } from 'src/features/Firewalls/FirewallLanding/constants'; import type { Firewall } from '@linode/api-v4'; authenticate(); -describe.skip('delete firewall', () => { - before(() => { - cleanUp('firewalls'); - }); +// Firewall GET API request performance issues need to be addressed in order to unskip this test +// See M3-9619 +describe('delete firewall', () => { + // TODO Restore clean-up when `deletes a firewall` test is unskipped. + // before(() => { + // cleanUp('firewalls'); + // }); beforeEach(() => { cy.tag('method:e2e'); }); @@ -23,7 +32,7 @@ describe.skip('delete firewall', () => { * - Confirms that firewall is still in landing page list after canceled operation. * - Confirms that firewall is removed from landing page list after confirmed operation. */ - it('deletes a firewall', () => { + it.skip('deletes a firewall', () => { const firewallRequest = firewallFactory.build({ label: randomLabel(), }); @@ -80,4 +89,120 @@ describe.skip('delete firewall', () => { } ); }); + + /* + * - Confirms that Firewalls that are designated as default cannot be deleted or disabled. + * - Confirms that Firewalls that are not designated as default can be deleted and disabled. + */ + it('cannot delete default Firewalls', () => { + const mockFirewallDefaultConfig = firewallFactory.build(); + const mockFirewallDefaultVpc = firewallFactory.build(); + const mockFirewallDefaultPublic = firewallFactory.build(); + const mockFirewallDefaultNodeBalancer = firewallFactory.build(); + const mockFirewallNotDefault = firewallFactory.build(); + + const mockFirewallDefaults = [ + mockFirewallDefaultConfig, + mockFirewallDefaultVpc, + mockFirewallDefaultPublic, + mockFirewallDefaultNodeBalancer, + ]; + const mockFirewalls = [...mockFirewallDefaults, mockFirewallNotDefault]; + + // TODO M3-9775 - Delete feature flag mocks once `linodeInterfaces` flag is deleted. + mockAppendFeatureFlags({ + linodeInterfaces: { + enabled: true, + }, + }); + + // TODO Delete account capability mock once all accounts have Linode interfaces capability. + mockGetAccount( + accountFactory.build({ + capabilities: ['Cloud Firewall', 'Linode Interfaces', 'Linodes'], + }) + ); + + mockGetFirewalls(mockFirewalls); + mockGetFirewallSettings({ + default_firewall_ids: { + linode: mockFirewallDefaultConfig.id, + nodebalancer: mockFirewallDefaultNodeBalancer.id, + vpc_interface: mockFirewallDefaultVpc.id, + public_interface: mockFirewallDefaultPublic.id, + }, + }); + + cy.visitWithLogin('/firewalls'); + + // Confirm that each Firewall that is designated as a default is listed + // as expected and that they cannot be disabled or deleted. + mockFirewallDefaults.forEach((mockFirewall) => { + cy.findByText(mockFirewall.label) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('DEFAULT').should('be.visible'); + ui.button + .findByTitle('Disable') + .should('be.visible') + .should('be.disabled') + .focus(); + + ui.tooltip + .findByText(DEFAULT_FIREWALL_TOOLTIP_TEXT) + .should('be.visible'); + + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.disabled') + .focus(); + + ui.tooltip + .findByText(DEFAULT_FIREWALL_TOOLTIP_TEXT) + .should('be.visible'); + + // Dismiss the tooltip by focusing on another element. + cy.findByText(mockFirewall.label).focus(); + }); + }); + + // Confirm that Firewalls that are not designated as default can be disabled + // and deleted as expected. + cy.findByText(mockFirewallNotDefault.label) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('DEFAULT').should('not.exist'); + + ui.button + .findByTitle('Disable') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + ui.dialog + .findByTitle(`Disable Firewall ${mockFirewallNotDefault.label}?`) + .should('be.visible') + .within(() => { + ui.button.findByTitle('Cancel').should('be.visible').click(); + }); + + cy.findByText(mockFirewallNotDefault.label) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + ui.dialog + .findByTitle(`Delete Firewall ${mockFirewallNotDefault.label}?`) + .should('be.visible'); + }); }); diff --git a/packages/manager/cypress/e2e/core/firewalls/firewall-landing-page.spec.ts b/packages/manager/cypress/e2e/core/firewalls/firewall-landing-page.spec.ts new file mode 100644 index 00000000000..eae244dd711 --- /dev/null +++ b/packages/manager/cypress/e2e/core/firewalls/firewall-landing-page.spec.ts @@ -0,0 +1,216 @@ +import { linodeFactory, nodeBalancerFactory } from '@linode/utilities'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { + mockGetFirewalls, + mockGetFirewallSettings, +} from 'support/intercepts/firewalls'; +import { mockGetLinodes } from 'support/intercepts/linodes'; +import { mockGetNodeBalancers } from 'support/intercepts/nodebalancers'; +import { ui } from 'support/ui'; +import { randomLabel } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; + +import { + accountFactory, + firewallFactory, + firewallRuleFactory, +} from 'src/factories'; + +describe('confirms Firewalls landing page empty state is shown when no Firewalls exist', () => { + /* + * - Confirms that existing Firewalls are listed on the Firewalls landing page. + * - Confirms that different Firewall configurations are displayed as expected. + * - Confirms that users can navigate to entity detail pages using the "Services" column links. + */ + it('lists all Firewalls', () => { + const mockLinode = linodeFactory.build({ + label: randomLabel(), + region: chooseRegion().id, + }); + + const mockNodeBalancer = nodeBalancerFactory.build({ + label: randomLabel(), + region: chooseRegion().id, + }); + + const mockFirewall = firewallFactory.build({ + label: randomLabel(), + rules: { + inbound: undefined, + outbound: undefined, + inbound_policy: 'DROP', + outbound_policy: 'ACCEPT', + }, + entities: [], + }); + + const mockFirewallWithEntities = firewallFactory.build({ + label: randomLabel(), + rules: { + inbound: undefined, + outbound: undefined, + inbound_policy: 'DROP', + outbound_policy: 'ACCEPT', + }, + entities: [ + { + type: 'linode', + id: mockLinode.id, + label: mockLinode.label, + url: `/linodes/${mockLinode.id}`, + }, + { + type: 'nodebalancer', + id: mockNodeBalancer.id, + label: mockNodeBalancer.label, + url: `/nodebalancers/${mockNodeBalancer.id}/summary`, + }, + ], + }); + + const mockFirewallWithRules = firewallFactory.build({ + label: randomLabel(), + rules: { + inbound_policy: 'DROP', + outbound_policy: 'DROP', + inbound: [ + firewallRuleFactory.build({ + action: 'ACCEPT', + }), + ], + outbound: firewallRuleFactory.buildList(2), + }, + entities: [], + }); + + const mockFirewalls = [ + mockFirewall, + mockFirewallWithEntities, + mockFirewallWithRules, + ]; + + mockGetLinodes([mockLinode]); + mockGetNodeBalancers([mockNodeBalancer]); + mockGetFirewalls(mockFirewalls); + cy.visitWithLogin('/firewalls'); + + // Confirm that each Firewall is listed with the expected information. + cy.findByText(mockFirewall.label) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('Enabled').should('be.visible'); + cy.findByText('No rules').should('be.visible'); + cy.findByText('None assigned').should('be.visible'); + }); + + cy.findByText(mockFirewallWithEntities.label) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('Enabled').should('be.visible'); + cy.findByText('No rules').should('be.visible'); + cy.contains(`${mockLinode.label}, ${mockNodeBalancer.label}`).should( + 'be.visible' + ); + }); + + cy.findByText(mockFirewallWithRules.label) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('Enabled').should('be.visible'); + cy.findByText('1 Inbound / 2 Outbound').should('be.visible'); + cy.findByText('None assigned').should('be.visible'); + }); + + cy.findByText(mockLinode.label).click(); + cy.url().should('endWith', `/linodes/${mockLinode.id}/networking`); + cy.go('back'); + + cy.findByText(mockNodeBalancer.label).click(); + cy.url().should('endWith', `/nodebalancers/${mockNodeBalancer.id}/summary`); + }); + + /* + * - Confirms that Firewalls that are designated as defaults are listed on landing page with chip. + * - Confirms that Firewalls that are not designated as defaults are not listed with a chip. + */ + it('lists default Firewalls', () => { + /* + * TODO M3-9775 - Remove feature flag and account mocks and combine this test with + * the 'lists all Firewalls' when `linodeInterfaces` flag is removed. + */ + const mockFirewallDefault = firewallFactory.build({ label: randomLabel() }); + const mockFirewallNotDefault = firewallFactory.build({ + label: randomLabel(), + }); + + mockAppendFeatureFlags({ + linodeInterfaces: { + enabled: true, + }, + }); + mockGetAccount( + accountFactory.build({ + capabilities: ['Linodes', 'Linode Interfaces', 'Cloud Firewall'], + }) + ); + mockGetFirewalls([mockFirewallDefault, mockFirewallNotDefault]); + mockGetFirewallSettings({ + default_firewall_ids: { + linode: null, + nodebalancer: null, + public_interface: mockFirewallDefault.id, + vpc_interface: null, + }, + }); + + cy.visitWithLogin('/firewalls'); + + // Confirm that "DEFAULT" chip is listed next to Firewall that's designated as a default. + cy.findByText(mockFirewallDefault.label) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('DEFAULT').should('be.visible'); + }); + + // Confirm that "DEFAULT" chip is not listed next to a Firewall that is not designated as a default. + cy.findByText(mockFirewallNotDefault.label) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('DEFAULT').should('not.exist'); + }); + }); + + /* + * - Confirms that Getting Started Guides is listed on landing page. + * - Confirms that Video Playlist is listed on landing page. + * - Confirms that clicking on Create Firewall button navigates user to firewall create page. + */ + it('shows the empty state when no Firewalls exist', () => { + mockGetFirewalls([]).as('getFirewalls'); + + cy.visitWithLogin('/firewalls'); + cy.wait(['@getFirewalls']); + + cy.findByText('Secure cloud-based firewall').should('be.visible'); + cy.findByText( + 'Control network traffic to and from Linode Compute Instances with a simple management interface' + ).should('be.visible'); + cy.findByText('Getting Started Guides').should('be.visible'); + cy.findByText('Video Playlist').should('be.visible'); + + // Create Firewall button exists and clicking it navigates user to create firewall page. + ui.button + .findByTitle('Create Firewall') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.url().should('endWith', '/firewalls/create'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/firewalls/landing-page-empty-state.spec.ts b/packages/manager/cypress/e2e/core/firewalls/landing-page-empty-state.spec.ts deleted file mode 100644 index ceeff489de1..00000000000 --- a/packages/manager/cypress/e2e/core/firewalls/landing-page-empty-state.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { mockGetFirewalls } from 'support/intercepts/firewalls'; -import { ui } from 'support/ui'; - -describe('confirms Firewalls landing page empty state is shown when no Firewalls exist', () => { - /* - * - Confirms that Getting Started Guides is listed on landing page. - * - Confirms that Video Playlist is listed on landing page. - * - Confirms that clicking on Create Firewall button navigates user to firewall create page. - */ - it('shows the empty state when no Firewalls exist', () => { - mockGetFirewalls([]).as('getFirewalls'); - - cy.visitWithLogin('/firewalls'); - cy.wait(['@getFirewalls']); - - cy.findByText('Secure cloud-based firewall').should('be.visible'); - cy.findByText( - 'Control network traffic to and from Linode Compute Instances with a simple management interface' - ).should('be.visible'); - cy.findByText('Getting Started Guides').should('be.visible'); - cy.findByText('Video Playlist').should('be.visible'); - - // Create Firewall button exists and clicking it navigates user to create firewall page. - ui.button - .findByTitle('Create Firewall') - .should('be.visible') - .should('be.enabled') - .click(); - - cy.url().should('endWith', '/firewalls/create'); - }); -}); diff --git a/packages/manager/cypress/e2e/core/firewalls/migrate-linode-with-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/migrate-linode-with-firewall.spec.ts index 0070a252647..4ad04ce9844 100644 --- a/packages/manager/cypress/e2e/core/firewalls/migrate-linode-with-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/migrate-linode-with-firewall.spec.ts @@ -144,98 +144,103 @@ describe('Migrate Linode With Firewall', () => { * - Uses real API data to create a Firewall, attach a Linode to it, then migrate the Linode. */ it.skip('migrates linode with firewall - real data', () => { - cy.tag('method:e2e', 'purpose:dcTesting'); - const [migrationRegionStart, migrationRegionEnd] = chooseRegions(2); - const firewallLabel = randomLabel(); - const linodePayload = createLinodeRequestFactory.build({ - label: randomLabel(), - region: migrationRegionStart.id, - }); + cy.tag('method:e2e', 'purpose:dcTesting', 'env:multipleRegions'); + + // Execute the body of the test inside Cypress's command queue to ensure + // that logic that requires multiple regions only executes after tags are evaluated. + cy.defer(async () => {}).then(() => { + const [migrationRegionStart, migrationRegionEnd] = chooseRegions(2); + const firewallLabel = randomLabel(); + const linodePayload = createLinodeRequestFactory.build({ + label: randomLabel(), + region: migrationRegionStart.id, + }); - interceptCreateFirewall().as('createFirewall'); - interceptGetFirewalls().as('getFirewalls'); - - // Create a Linode, then navigate to the Firewalls landing page. - cy.defer(() => - createTestLinode(linodePayload, { securityMethod: 'powered_off' }) - ).then((linode: Linode) => { - interceptMigrateLinode(linode.id).as('migrateLinode'); - cy.visitWithLogin('/firewalls'); - cy.wait('@getFirewalls'); - - ui.button - .findByTitle('Create Firewall') - .should('be.visible') - .should('be.enabled') - .click(); - - ui.drawer - .findByTitle('Create Firewall') - .should('be.visible') - .within(() => { - cy.findByText('Label').should('be.visible').click(); - cy.focused().type(firewallLabel); - - cy.findByText('Linodes').should('be.visible').click(); - cy.focused().type(linode.label); - - ui.autocompletePopper - .findByTitle(linode.label) - .should('be.visible') - .click(); - - // Click on the Select again to dismiss the autocomplete popper. - cy.findByLabelText('Linodes').should('be.visible').click(); - - ui.buttonGroup - .findButtonByTitle('Create Firewall') - .should('be.visible') - .should('be.enabled') - .click(); - }); + interceptCreateFirewall().as('createFirewall'); + interceptGetFirewalls().as('getFirewalls'); - cy.wait('@createFirewall'); - cy.visitWithLogin(`/linodes/${linode.id}`); - cy.get('[data-qa-link-text="true"]') - .should('be.visible') - .within(() => { - cy.findByText('linodes').should('be.visible'); - }); + // Create a Linode, then navigate to the Firewalls landing page. + cy.defer(() => + createTestLinode(linodePayload, { securityMethod: 'powered_off' }) + ).then((linode: Linode) => { + interceptMigrateLinode(linode.id).as('migrateLinode'); + cy.visitWithLogin('/firewalls'); + cy.wait('@getFirewalls'); - // Make sure Linode is running before attempting to migrate. - cy.get('[data-qa-linode-status]').within(() => { - cy.findByText('OFFLINE'); - }); + ui.button + .findByTitle('Create Firewall') + .should('be.visible') + .should('be.enabled') + .click(); - ui.actionMenu - .findByTitle(`Action menu for Linode ${linode.label}`) - .should('be.visible') - .click(); - - ui.actionMenuItem.findByTitle('Migrate').should('be.visible').click(); - - ui.dialog - .findByTitle(`Migrate Linode ${linode.label} to another region`) - .should('be.visible') - .within(() => { - // Click "Accept" check box. - cy.findByText('Accept').should('be.visible').click(); - - // Select region for migration. - ui.regionSelect.find().click(); - ui.regionSelect - .findItemByRegionLabel(migrationRegionEnd.label) - .click(); - - // Initiate migration. - ui.button - .findByTitle('Enter Migration Queue') - .should('be.visible') - .should('be.enabled') - .click(); + ui.drawer + .findByTitle('Create Firewall') + .should('be.visible') + .within(() => { + cy.findByText('Label').should('be.visible').click(); + cy.focused().type(firewallLabel); + + cy.findByText('Linodes').should('be.visible').click(); + cy.focused().type(linode.label); + + ui.autocompletePopper + .findByTitle(linode.label) + .should('be.visible') + .click(); + + // Click on the Select again to dismiss the autocomplete popper. + cy.findByLabelText('Linodes').should('be.visible').click(); + + ui.buttonGroup + .findButtonByTitle('Create Firewall') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait('@createFirewall'); + cy.visitWithLogin(`/linodes/${linode.id}`); + cy.get('[data-qa-link-text="true"]') + .should('be.visible') + .within(() => { + cy.findByText('linodes').should('be.visible'); + }); + + // Make sure Linode is running before attempting to migrate. + cy.get('[data-qa-linode-status]').within(() => { + cy.findByText('OFFLINE'); }); - cy.wait('@migrateLinode').its('response.statusCode').should('eq', 200); + ui.actionMenu + .findByTitle(`Action menu for Linode ${linode.label}`) + .should('be.visible') + .click(); + + ui.actionMenuItem.findByTitle('Migrate').should('be.visible').click(); + + ui.dialog + .findByTitle(`Migrate Linode ${linode.label} to another region`) + .should('be.visible') + .within(() => { + // Click "Accept" check box. + cy.findByText('Accept').should('be.visible').click(); + + // Select region for migration. + ui.regionSelect.find().click(); + ui.regionSelect + .findItemByRegionLabel(migrationRegionEnd.label) + .click(); + + // Initiate migration. + ui.button + .findByTitle('Enter Migration Queue') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait('@migrateLinode').its('response.statusCode').should('eq', 200); + }); }); }); }); diff --git a/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts index e574a469199..e90d64d3c02 100644 --- a/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts @@ -167,6 +167,8 @@ const createLinodeAndFirewall = async ( }; authenticate(); +// Firewall GET API request performance issues need to be addressed in order to unskip this test +// See M3-9619 describe.skip('update firewall', () => { before(() => { cleanUp('firewalls'); diff --git a/packages/manager/cypress/e2e/core/general/gdpr-agreement.spec.ts b/packages/manager/cypress/e2e/core/general/gdpr-agreement.spec.ts index 18755ff73db..c89c6cbc6c9 100644 --- a/packages/manager/cypress/e2e/core/general/gdpr-agreement.spec.ts +++ b/packages/manager/cypress/e2e/core/general/gdpr-agreement.spec.ts @@ -54,19 +54,19 @@ describe('GDPR agreement', () => { // Paris should have the agreement ui.regionSelect.find().click(); - ui.regionSelect.findItemByRegionId('fr-par').click(); + ui.regionSelect.findItemByRegionId('fr-par', mockRegions).click(); cy.get('[data-testid="eu-agreement-checkbox"]').should('be.visible'); cy.wait('@getAgreements'); // London should have the agreement ui.regionSelect.find().click(); - ui.regionSelect.findItemByRegionId('eu-west').click(); + ui.regionSelect.findItemByRegionId('eu-west', mockRegions).click(); cy.get('[data-testid="eu-agreement-checkbox"]').should('be.visible'); // Newark should not have the agreement ui.regionSelect.find().click(); - ui.regionSelect.findItemByRegionId('us-east').click(); + ui.regionSelect.findItemByRegionId('us-east', mockRegions).click(); cy.get('[data-testid="eu-agreement-checkbox"]').should('not.exist'); }); @@ -83,19 +83,19 @@ describe('GDPR agreement', () => { // Paris should not have the agreement ui.regionSelect.find().click(); - ui.regionSelect.findItemByRegionId('fr-par').click(); + ui.regionSelect.findItemByRegionId('fr-par', mockRegions).click(); cy.get('[data-testid="eu-agreement-checkbox"]').should('not.exist'); cy.wait('@getAgreements'); // London should not have the agreement ui.regionSelect.find().click(); - ui.regionSelect.findItemByRegionId('eu-west').click(); + ui.regionSelect.findItemByRegionId('eu-west', mockRegions).click(); cy.get('[data-testid="eu-agreement-checkbox"]').should('not.exist'); // Newark should not have the agreement ui.regionSelect.find().click(); - ui.regionSelect.findItemByRegionId('us-east').click(); + ui.regionSelect.findItemByRegionId('us-east', mockRegions).click(); cy.get('[data-testid="eu-agreement-checkbox"]').should('not.exist'); }); @@ -114,7 +114,7 @@ describe('GDPR agreement', () => { // Paris should have the agreement ui.regionSelect.find().click(); - ui.regionSelect.findItemByRegionId('fr-par').click(); + ui.regionSelect.findItemByRegionId('fr-par', mockRegions).click(); cy.wait('@getAgreements'); diff --git a/packages/manager/cypress/e2e/core/general/smoke-deep-link.spec.ts b/packages/manager/cypress/e2e/core/general/smoke-deep-link.spec.ts index 8cbe786110b..821ea4a8103 100644 --- a/packages/manager/cypress/e2e/core/general/smoke-deep-link.spec.ts +++ b/packages/manager/cypress/e2e/core/general/smoke-deep-link.spec.ts @@ -15,7 +15,7 @@ describe('smoke - deep links', () => { cy.log(`Go to ${page.name}`); page.goWithUI?.forEach((uiPath) => { cy.log(`by ${uiPath.name}`); - expect(uiPath.name).not.to.be.empty; + cy.findByText(uiPath.name).should('not.be.empty'); uiPath.go(); cy.url().should('be.eq', `${Cypress.config('baseUrl')}${page.url}`); }); diff --git a/packages/manager/cypress/e2e/core/images/images-empty-landing-page.spec.ts b/packages/manager/cypress/e2e/core/images/images-empty-landing-page.spec.ts index 49a4a3e3665..483e2bdd60c 100644 --- a/packages/manager/cypress/e2e/core/images/images-empty-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/images/images-empty-landing-page.spec.ts @@ -1,6 +1,5 @@ -import { profileFactory } from '@src/factories'; +import { grantsFactory, profileFactory } from '@linode/utilities'; import { accountUserFactory } from '@src/factories/accountUsers'; -import { grantsFactory } from '@src/factories/grants'; import { mockGetUser } from 'support/intercepts/account'; import { mockGetAllImages } from 'support/intercepts/images'; import { diff --git a/packages/manager/cypress/e2e/core/images/images-non-empty-landing-page.spec.ts b/packages/manager/cypress/e2e/core/images/images-non-empty-landing-page.spec.ts index 394ebe42563..7618066ce99 100644 --- a/packages/manager/cypress/e2e/core/images/images-non-empty-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/images/images-non-empty-landing-page.spec.ts @@ -1,3 +1,4 @@ +import { grantsFactory, profileFactory } from '@linode/utilities'; import { mockGetUser } from 'support/intercepts/account'; import { mockGetAllImages } from 'support/intercepts/images'; import { @@ -7,10 +8,8 @@ import { import { ui } from 'support/ui'; import { randomLabel } from 'support/util/random'; -import { grantsFactory } from 'src/factories'; -import { profileFactory } from 'src/factories'; -import { accountUserFactory } from 'src/factories'; import { imageFactory } from 'src/factories'; +import { accountUserFactory } from 'src/factories'; import type { Image } from '@linode/api-v4'; @@ -50,14 +49,14 @@ function checkActionMenu(tableAlias: string, mockImages: any[]) { describe('image landing checks for non-empty state with restricted user', () => { beforeEach(() => { - const mockImages: Image[] = new Array(3).fill(null).map( - (_item: null, index: number): Image => { + const mockImages: Image[] = new Array(3) + .fill(null) + .map((_item: null, index: number): Image => { return imageFactory.build({ label: `Image ${index}`, tags: [index % 2 == 0 ? 'even' : 'odd', 'nums'], }); - } - ); + }); // Mock setup to display the Image landing page in an non-empty state mockGetAllImages(mockImages).as('getImages'); diff --git a/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts b/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts index 5d78e70bfcf..715d7885f45 100644 --- a/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts +++ b/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts @@ -113,7 +113,16 @@ const assertProcessing = (label: string, id: string) => { * @param label - Label to apply to uploaded image. */ const uploadImage = (label: string) => { - const region = chooseRegion({ capabilities: ['Object Storage'] }); + // Disallow these regions from being returned by `chooseRegion` because they do not support Machine Images: + // - au-mel + // - gb-lon + // - sg-sin-2 + // + // See also BAC-862. + const region = chooseRegion({ + capabilities: ['Object Storage'], + exclude: ['au-mel', 'gb-lon', 'sg-sin-2'], + }); const upload = 'machine-images/test-image.gz'; cy.visitWithLogin('/images/create/upload'); diff --git a/packages/manager/cypress/e2e/core/images/search-images.spec.ts b/packages/manager/cypress/e2e/core/images/search-images.spec.ts index c0c60975113..ea6bda69636 100644 --- a/packages/manager/cypress/e2e/core/images/search-images.spec.ts +++ b/packages/manager/cypress/e2e/core/images/search-images.spec.ts @@ -59,8 +59,8 @@ describe('Search Images', () => { // Search for the first image by label, confirm it's the only one shown. cy.findByPlaceholderText('Search Images').type(image1.label); - expect(cy.contains(image1.label).should('be.visible')); - expect(cy.contains(image2.label).should('not.exist')); + cy.contains(image1.label).should('be.visible'); + cy.contains(image2.label).should('not.exist'); // Clear search, confirm both images are shown. cy.findByTestId('clear-images-search').click(); diff --git a/packages/manager/cypress/e2e/core/images/smoke-create-image.spec.ts b/packages/manager/cypress/e2e/core/images/smoke-create-image.spec.ts index 00d9f4c319a..8a4866a8d03 100644 --- a/packages/manager/cypress/e2e/core/images/smoke-create-image.spec.ts +++ b/packages/manager/cypress/e2e/core/images/smoke-create-image.spec.ts @@ -1,4 +1,8 @@ -import { linodeFactory } from '@linode/utilities'; +import { + grantsFactory, + linodeFactory, + profileFactory, +} from '@linode/utilities'; import { mockGetUser } from 'support/intercepts/account'; import { mockGetEvents } from 'support/intercepts/events'; import { mockCreateImage } from 'support/intercepts/images'; @@ -10,12 +14,7 @@ import { import { ui } from 'support/ui'; import { randomLabel, randomNumber, randomPhrase } from 'support/util/random'; -import { - accountUserFactory, - eventFactory, - grantsFactory, - profileFactory, -} from 'src/factories'; +import { accountUserFactory, eventFactory } from 'src/factories'; import { linodeDiskFactory } from 'src/factories/disk'; import { imageFactory } from 'src/factories/images'; diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts index ebe1e5a933a..c85273cca62 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts @@ -2,6 +2,7 @@ * @file LKE creation end-to-end tests. */ import { + accountBetaFactory, dedicatedTypeFactory, linodeTypeFactory, pluralize, @@ -43,10 +44,9 @@ import { } from 'support/intercepts/regions'; import { ui } from 'support/ui'; import { randomItem, randomLabel, randomNumber } from 'support/util/random'; -import { chooseRegion, getRegionById } from 'support/util/regions'; +import { chooseRegion, extendRegion } from 'support/util/regions'; import { - accountBetaFactory, accountFactory, kubeLinodeFactory, kubernetesClusterFactory, @@ -535,8 +535,12 @@ describe('LKE Cluster Creation with DC-specific pricing', () => { * - Confirms that HA helper text updates dynamically to display pricing when a region is selected. */ it('can dynamically update prices when creating an LKE cluster based on region', () => { - // In staging API, only the Dallas region is available for LKE creation - const dcSpecificPricingRegion = getRegionById('us-central'); + const dcSpecificPricingRegion = extendRegion( + regionFactory.build({ + capabilities: ['Linodes', 'Kubernetes', 'Kubernetes Enterprise'], + }) + ); + mockGetRegions([dcSpecificPricingRegion]).as('getRegions'); const clusterLabel = randomLabel(); const clusterPlans = new Array(2) .fill(null) @@ -553,7 +557,7 @@ describe('LKE Cluster Creation with DC-specific pricing', () => { cy.url().should('endWith', '/kubernetes/create'); mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); - cy.wait(['@getLinodeTypes']); + cy.wait(['@getRegions', '@getLinodeTypes']); // Confirm that, without a region selected, no pricing information is displayed. @@ -582,6 +586,7 @@ describe('LKE Cluster Creation with DC-specific pricing', () => { cy.focused().type(`${dcSpecificPricingRegion.label}{enter}`); // Confirm that HA price updates dynamically once region selection is made. + // eslint-disable-next-line sonarjs/slow-regex cy.contains(/\$.*\/month/).should('be.visible'); cy.get('[data-testid="ha-radio-button-yes"]').should('be.visible').click(); diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts index 4830646d369..3f1bb652e04 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts @@ -1,4 +1,8 @@ -import { linodeFactory, linodeTypeFactory } from '@linode/utilities'; +import { + linodeFactory, + linodeTypeFactory, + regionFactory, +} from '@linode/utilities'; import { DateTime } from 'luxon'; import { dcPricingMockLinodeTypes } from 'support/constants/dc-specific-pricing'; import { latestKubernetesVersion } from 'support/constants/lke'; @@ -31,10 +35,11 @@ import { mockUpdateNodePool, mockUpdateNodePoolError, } from 'support/intercepts/lke'; +import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; import { buildArray } from 'support/util/arrays'; import { randomIp, randomLabel, randomString } from 'support/util/random'; -import { getRegionById } from 'support/util/regions'; +import { extendRegion } from 'support/util/regions'; import { accountFactory, @@ -251,7 +256,13 @@ describe('LKE cluster updates', () => { it('can upgrade enterprise kubernetes version from the details page', () => { const oldVersion = '1.31.1+lke1'; const newVersion = '1.31.1+lke2'; - + const clusterRegion = extendRegion( + regionFactory.build({ + capabilities: ['Linodes', 'Kubernetes', 'Kubernetes Enterprise'], + id: 'us-central', + }) + ); + mockGetRegions([clusterRegion]).as('getRegions'); mockGetAccount( accountFactory.build({ capabilities: ['Kubernetes Enterprise'], @@ -265,6 +276,7 @@ describe('LKE cluster updates', () => { const mockCluster = kubernetesClusterFactory.build({ k8s_version: oldVersion, + region: clusterRegion.id, tier: 'enterprise', }); @@ -295,6 +307,7 @@ describe('LKE cluster updates', () => { cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); cy.wait([ + '@getRegions', '@getAccount', '@getCluster', '@getNodePools', @@ -503,19 +516,19 @@ describe('LKE cluster updates', () => { /* * - Confirms UI flow when enabling and disabling node pool autoscaling using mocked API responses. - * - Confirms that errors are shown when attempting to autoscale using invalid values. + * - Confirms that errors are shown when attempting to autoscale using invalid values based on the cluster tier. * - Confirms that UI updates to reflect node pool autoscale state. */ - it('can toggle autoscaling', () => { + it('can toggle autoscaling on a standard tier cluster', () => { const autoscaleMin = 3; const autoscaleMax = 10; - const minWarning = 'Minimum must be between 1 and 99 nodes and cannot be greater than Maximum.'; const maxWarning = 'Maximum must be between 1 and 100 nodes.'; const mockCluster = kubernetesClusterFactory.build({ k8s_version: latestKubernetesVersion, + tier: 'standard', }); const mockNodePool = nodePoolFactory.build({ @@ -538,9 +551,18 @@ describe('LKE cluster updates', () => { mockGetKubernetesVersions().as('getVersions'); mockGetDashboardUrl(mockCluster.id); mockGetApiEndpoints(mockCluster.id); + mockGetAccount( + accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }) + ).as('getAccount'); + // TODO LKE-E: Remove once feature is in GA + mockAppendFeatureFlags({ + lkeEnterprise: { enabled: true, la: true }, + }); cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); - cy.wait(['@getCluster', '@getNodePools', '@getVersions']); + cy.wait(['@getAccount', '@getCluster', '@getNodePools', '@getVersions']); // Click "Autoscale Pool", enable autoscaling, and set min and max values. mockUpdateNodePool(mockCluster.id, mockNodePoolAutoscale).as( @@ -625,6 +647,115 @@ describe('LKE cluster updates', () => { ); }); + /* + * - Confirms UI flow when enabling and disabling node pool autoscaling using mocked API responses. + * - Confirms that errors are shown when attempting to autoscale using invalid values based on the cluster tier. + * - Confirms that UI updates to reflect node pool autoscale state. + */ + it('can toggle autoscaling on an enterprise tier cluster', () => { + const autoscaleMin = 1; + const autoscaleMax = 500; + + const minWarning = + 'Minimum must be between 1 and 499 nodes and cannot be greater than Maximum.'; + const maxWarning = 'Maximum must be between 1 and 500 nodes.'; + + const mockCluster = kubernetesClusterFactory.build({ + k8s_version: latestKubernetesVersion, + tier: 'enterprise', + }); + + const mockNodePool = nodePoolFactory.build({ + count: 1, + nodes: kubeLinodeFactory.buildList(1), + type: 'g6-dedicated-4', + }); + + const mockNodePoolAutoscale = { + ...mockNodePool, + autoscaler: { + enabled: true, + max: autoscaleMax, + min: autoscaleMin, + }, + }; + + mockGetCluster(mockCluster).as('getCluster'); + mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools'); + mockGetKubernetesVersions().as('getVersions'); + mockGetDashboardUrl(mockCluster.id); + mockGetApiEndpoints(mockCluster.id); + mockGetAccount( + accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }) + ).as('getAccount'); + // TODO LKE-E: Remove once feature is in GA + mockAppendFeatureFlags({ + lkeEnterprise: { enabled: true, la: true }, + }); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); + cy.wait(['@getAccount', '@getCluster', '@getNodePools', '@getVersions']); + + // Click "Autoscale Pool", enable autoscaling, and set min and max values. + mockUpdateNodePool(mockCluster.id, mockNodePoolAutoscale).as( + 'toggleAutoscale' + ); + mockGetClusterPools(mockCluster.id, [mockNodePoolAutoscale]).as( + 'getNodePools' + ); + ui.button + .findByTitle('Autoscale Pool') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.dialog + .findByTitle('Autoscale Pool') + .should('be.visible') + .within(() => { + cy.findByText('Autoscale').should('be.visible').click(); + + cy.findByLabelText('Min').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type(`${autoscaleMin - 1}`); + + cy.findByText(minWarning).should('be.visible'); + + cy.findByLabelText('Max').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type('501'); + + cy.findByText(maxWarning).should('be.visible'); + cy.findByText(minWarning).should('not.exist'); + + cy.findByLabelText('Max').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type(`${autoscaleMax}`); + + cy.findByText(minWarning).should('not.exist'); + cy.findByText(maxWarning).should('not.exist'); + + ui.button.findByTitle('Save Changes').should('be.disabled'); + + cy.findByLabelText('Min').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type(`${autoscaleMin + 1}`); + + ui.button.findByTitle('Save Changes').should('be.visible').click(); + }); + + // Wait for API response and confirm that UI updates to reflect autoscale. + cy.wait(['@toggleAutoscale', '@getNodePools']); + ui.toast.assertMessage( + `Autoscaling updated for Node Pool ${mockNodePool.id}.` + ); + cy.findByText(`(Min ${autoscaleMin} / Max ${autoscaleMax})`).should( + 'be.visible' + ); + }); + /* * - Confirms node pool resize UI flow using mocked API responses. * - Confirms that pool size can be increased and decreased. @@ -845,8 +976,16 @@ describe('LKE cluster updates', () => { * - Confirms that details page updates to reflect change when pools are added or deleted. */ it('can add and delete node pools', () => { + const clusterRegion = extendRegion( + regionFactory.build({ + capabilities: ['Linodes', 'Kubernetes', 'Kubernetes Enterprise'], + id: 'us-east', + }) + ); + mockGetRegions([clusterRegion]).as('getRegions'); const mockCluster = kubernetesClusterFactory.build({ k8s_version: latestKubernetesVersion, + region: clusterRegion.id, }); const mockNodePool = nodePoolFactory.build({ @@ -868,7 +1007,7 @@ describe('LKE cluster updates', () => { mockGetApiEndpoints(mockCluster.id); cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); - cy.wait(['@getCluster', '@getNodePools', '@getVersions']); + cy.wait(['@getRegions', '@getCluster', '@getNodePools', '@getVersions']); // Assert that initial node pool is shown on the page. cy.findByText('Dedicated 8 GB', { selector: 'h2' }).should('be.visible'); @@ -1928,7 +2067,13 @@ describe('LKE cluster updates', () => { * - Confirms that details page updates total cluster price with DC-specific pricing. */ it('can resize pools with DC-specific prices', () => { - const dcSpecificPricingRegion = getRegionById('us-east'); + const dcSpecificPricingRegion = extendRegion( + regionFactory.build({ + capabilities: ['Linodes', 'Kubernetes', 'Kubernetes Enterprise'], + id: 'us-east', + }) + ); + mockGetRegions([dcSpecificPricingRegion]).as('getRegions'); const mockPlanType = extendType(dcPricingMockLinodeTypes[0]); const mockCluster = kubernetesClusterFactory.build({ @@ -1976,6 +2121,7 @@ describe('LKE cluster updates', () => { cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); cy.wait([ + '@getRegions', '@getCluster', '@getNodePools', '@getLinodes', @@ -2073,8 +2219,13 @@ describe('LKE cluster updates', () => { * - Confirms that details page updates total cluster price with DC-specific pricing. */ it('can add node pools with DC-specific prices', () => { - const dcSpecificPricingRegion = getRegionById('us-east'); - + const dcSpecificPricingRegion = extendRegion( + regionFactory.build({ + capabilities: ['Linodes', 'Kubernetes', 'Kubernetes Enterprise'], + id: 'us-east', + }) + ); + mockGetRegions([dcSpecificPricingRegion]).as('getRegions'); const mockCluster = kubernetesClusterFactory.build({ control_plane: { high_availability: false, @@ -2108,6 +2259,7 @@ describe('LKE cluster updates', () => { cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); cy.wait([ + '@getRegions', '@getCluster', '@getNodePools', '@getVersions', @@ -2178,7 +2330,13 @@ describe('LKE cluster updates', () => { * - Confirms that details page still shows $0 pricing after resizing. */ it('can resize pools with region prices of $0', () => { - const dcSpecificPricingRegion = getRegionById('us-southeast'); + const dcSpecificPricingRegion = extendRegion( + regionFactory.build({ + capabilities: ['Linodes', 'Kubernetes', 'Kubernetes Enterprise'], + id: 'us-southeast', + }) + ); + mockGetRegions([dcSpecificPricingRegion]).as('getRegions'); const mockPlanType = extendType(dcPricingMockLinodeTypes[2]); const mockCluster = kubernetesClusterFactory.build({ @@ -2226,6 +2384,7 @@ describe('LKE cluster updates', () => { cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); cy.wait([ + '@getRegions', '@getCluster', '@getNodePools', '@getLinodes', @@ -2314,8 +2473,13 @@ describe('LKE cluster updates', () => { * - Confirms that details page still shows $0 pricing after adding node pool. */ it('can add node pools with region prices of $0', () => { - const dcSpecificPricingRegion = getRegionById('us-southeast'); - + const dcSpecificPricingRegion = extendRegion( + regionFactory.build({ + capabilities: ['Linodes', 'Kubernetes', 'Kubernetes Enterprise'], + id: 'us-southeast', + }) + ); + mockGetRegions([dcSpecificPricingRegion]).as('getRegions'); const mockPlanType = extendType(dcPricingMockLinodeTypes[2]); const mockCluster = kubernetesClusterFactory.build({ @@ -2349,6 +2513,7 @@ describe('LKE cluster updates', () => { cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); cy.wait([ + '@getRegions', '@getCluster', '@getNodePools', '@getVersions', @@ -2453,21 +2618,19 @@ describe('LKE ACL updates', () => { addresses: { ipv4: ['10.0.3.0/24'], ipv6: undefined }, enabled: false, }); - const mockUpdatedACLOptions1 = kubernetesControlPlaneACLOptionsFactory.build( - { + const mockUpdatedACLOptions1 = + kubernetesControlPlaneACLOptionsFactory.build({ addresses: { ipv4: ['10.0.0.0/24'], ipv6: undefined }, enabled: true, 'revision-id': mockRevisionId, - } - ); + }); const mockControlPaneACL = kubernetesControlPlaneACLFactory.build({ acl: mockACLOptions, }); - const mockUpdatedControlPlaneACL1 = kubernetesControlPlaneACLFactory.build( - { + const mockUpdatedControlPlaneACL1 = + kubernetesControlPlaneACLFactory.build({ acl: mockUpdatedACLOptions1, - } - ); + }); mockGetCluster(mockCluster).as('getCluster'); mockGetControlPlaneACL(mockCluster.id, mockControlPaneACL).as( @@ -2548,8 +2711,8 @@ describe('LKE ACL updates', () => { .click(); // update mocks - const mockUpdatedACLOptions2 = kubernetesControlPlaneACLOptionsFactory.build( - { + const mockUpdatedACLOptions2 = + kubernetesControlPlaneACLOptionsFactory.build({ addresses: { ipv4: ['10.0.0.0/24'], ipv6: [ @@ -2559,13 +2722,11 @@ describe('LKE ACL updates', () => { }, enabled: true, 'revision-id': mockRevisionId, - } - ); - const mockUpdatedControlPlaneACL2 = kubernetesControlPlaneACLFactory.build( - { + }); + const mockUpdatedControlPlaneACL2 = + kubernetesControlPlaneACLFactory.build({ acl: mockUpdatedACLOptions2, - } - ); + }); mockUpdateControlPlaneACL(mockCluster.id, mockUpdatedControlPlaneACL2).as( 'updateControlPlaneACL' ); @@ -2656,16 +2817,15 @@ describe('LKE ACL updates', () => { enabled: true, }); - const mockDisabledACLOptions = kubernetesControlPlaneACLOptionsFactory.build( - { + const mockDisabledACLOptions = + kubernetesControlPlaneACLOptionsFactory.build({ addresses: { ipv4: [''], ipv6: [''], }, enabled: false, 'revision-id': '', - } - ); + }); const mockControlPaneACL = kubernetesControlPlaneACLFactory.build({ acl: mockACLOptions, }); diff --git a/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-create.spec.ts index c32b7eb1d4e..04a3a5793d7 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-create.spec.ts @@ -1,9 +1,5 @@ -import { - accountUserFactory, - grantsFactory, - kubernetesClusterFactory, - profileFactory, -} from '@src/factories'; +import { grantsFactory, profileFactory } from '@linode/utilities'; +import { accountUserFactory, kubernetesClusterFactory } from '@src/factories'; import { mockGetUser } from 'support/intercepts/account'; import { mockCreateCluster } from 'support/intercepts/lke'; import { diff --git a/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts index ada334e759f..4a914e6eff8 100644 --- a/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts @@ -98,6 +98,7 @@ describe('linode backups', () => { .should('be.visible') .within(() => { // Confirm that user is warned of additional backup charges. + // eslint-disable-next-line sonarjs/slow-regex cy.contains(/.* This will add .* to your monthly bill\./).should( 'be.visible' ); diff --git a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts index 5bd831eddaf..c3f84851f87 100644 --- a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts @@ -2,6 +2,7 @@ import { createLinodeRequestFactory, linodeConfigInterfaceFactory, linodeFactory, + regionFactory, } from '@linode/utilities'; import { VLANFactory, @@ -31,6 +32,7 @@ import { mockGetLinodeVolumes, mockGetLinodes, } from 'support/intercepts/linodes'; +import { mockGetRegions } from 'support/intercepts/regions'; import { mockGetVLANs } from 'support/intercepts/vlans'; import { ui } from 'support/ui'; import { linodeCreatePage } from 'support/ui/pages'; @@ -42,7 +44,7 @@ import { randomNumber, randomString, } from 'support/util/random'; -import { chooseRegion, getRegionById } from 'support/util/regions'; +import { chooseRegion, extendRegion } from 'support/util/regions'; import type { Linode } from '@linode/api-v4'; @@ -306,9 +308,27 @@ describe('clone linode', () => { * - Confirms that notice is shown when selecting a region with a different price structure. */ it('shows DC-specific pricing information during clone flow', () => { - const initialRegion = getRegionById('us-west'); - const newRegion = getRegionById('us-east'); - + const mockRegions = [ + extendRegion( + regionFactory.build({ + capabilities: ['Linodes'], + country: 'us', + id: 'us-west', + label: 'Fremont, CA', + }) + ), + extendRegion( + regionFactory.build({ + capabilities: ['Linodes'], + country: 'us', + id: 'us-east', + label: 'Newark, NJ', + }) + ), + ]; + mockGetRegions(mockRegions).as('getRegions'); + const initialRegion = mockRegions[0]; + const newRegion = mockRegions[1]; const mockLinode = linodeFactory.build({ region: initialRegion.id, type: dcPricingMockLinodeTypes[0].id, @@ -323,7 +343,7 @@ describe('clone linode', () => { mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); cy.visitWithLogin(getLinodeCloneUrl(mockLinode)); - cy.wait(['@getLinode', '@getLinodes', '@getLinodeTypes']); + cy.wait(['@getRegions', '@getLinode', '@getLinodes', '@getLinodeTypes']); // Confirm there is a docs link to the pricing page. cy.findByText(dcPricingDocsLabel) diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-region-select.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-region-select.spec.ts index aa4275e4f34..3f7d28c425c 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-region-select.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-region-select.spec.ts @@ -86,6 +86,6 @@ describe('Linode Create Region Select', () => { cy.findByLabelText('Region').should('have.value', 'UK, London (eu-west)'); // Confirm that selecting a valid region updates the Plan Selection panel. - expect(cy.get('[data-testid="table-row-empty"]').should('not.exist')); + cy.get('[data-testid="table-row-empty"]').should('not.exist'); }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-disk-encryption.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-disk-encryption.spec.ts index 8f2e413fc3f..093b89bd120 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-disk-encryption.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-disk-encryption.spec.ts @@ -209,7 +209,7 @@ describe('Create Linode with Disk Encryption', () => { const requestPayload = xhr.request.body; const regionId = requestPayload['region']; expect(regionId).to.equal(mockLinode.region); - expect(requestPayload['disk_encryption']).to.be.undefined; + expect(requestPayload['disk_encryption']).to.equal(undefined); }); }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-firewall.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-firewall.spec.ts index 35865e33a6e..ec10e767da7 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-firewall.spec.ts @@ -4,8 +4,9 @@ import { mockCreateFirewall, mockCreateFirewallError, mockGetFirewalls, - mockGetTemplate, + mockGetFirewallTemplate, } from 'support/intercepts/firewalls'; +import { mockApiInternalUser } from 'support/intercepts/general'; import { mockCreateLinode, mockGetLinodeDetails, @@ -184,19 +185,6 @@ describe('Create Linode with Firewall', () => { * - Confirms that outgoing Linode Create API request specifies the selected Firewall to be attached. */ it('can generate and assign a compliant Firewall during Linode Create flow', () => { - cy.intercept( - { - middleware: true, - url: /\/v4(?:beta)?\/.*/, - }, - (req) => { - // Re-add internal-only header - req.on('response', (res) => { - res.headers['akamai-internal-account'] = '*'; - }); - } - ); - const linodeRegion = chooseRegion({ capabilities: ['Cloud Firewall'] }); const mockFirewall = firewallFactory.build({ @@ -214,9 +202,10 @@ describe('Create Linode with Firewall', () => { slug: 'akamai-non-prod', }); + mockApiInternalUser(); mockCreateFirewall(mockFirewall).as('createFirewall'); mockGetFirewalls([mockFirewall]).as('getFirewall'); - mockGetTemplate(mockTemplate).as('getTemplate'); + mockGetFirewallTemplate(mockTemplate).as('getTemplate'); mockCreateLinode(mockLinode).as('createLinode'); mockGetLinodeDetails(mockLinode.id, mockLinode); @@ -297,19 +286,6 @@ describe('Create Linode with Firewall', () => { * - Mocks an error response to the Create Firewall call. */ it('displays errors encountered while trying to generate a compliant firewall', () => { - cy.intercept( - { - middleware: true, - url: /\/v4(?:beta)?\/.*/, - }, - (req) => { - // Re-add internal-only header - req.on('response', (res) => { - res.headers['akamai-internal-account'] = '*'; - }); - } - ); - const mockFirewall = firewallFactory.build({ id: randomNumber(), label: randomLabel(), @@ -321,8 +297,9 @@ describe('Create Linode with Firewall', () => { const mockError = 'Mock error'; + mockApiInternalUser(); mockGetFirewalls([mockFirewall]).as('getFirewall'); - mockGetTemplate(mockTemplate).as('getTemplate'); + mockGetFirewallTemplate(mockTemplate).as('getTemplate'); mockCreateFirewallError(mockError).as('createFirewall'); cy.visitWithLogin('/linodes/create'); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-ssh-key.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-ssh-key.spec.ts index f3812319191..fac8dd00c69 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-ssh-key.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-ssh-key.spec.ts @@ -1,4 +1,4 @@ -import { linodeFactory } from '@linode/utilities'; +import { linodeFactory, sshKeyFactory } from '@linode/utilities'; import { mockGetUser, mockGetUsers } from 'support/intercepts/account'; import { mockCreateLinode } from 'support/intercepts/linodes'; import { mockCreateSSHKey } from 'support/intercepts/profile'; @@ -7,7 +7,7 @@ import { linodeCreatePage } from 'support/ui/pages'; import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; -import { accountUserFactory, sshKeyFactory } from 'src/factories'; +import { accountUserFactory } from 'src/factories'; describe('Create Linode with SSH Key', () => { /* diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts index b7ffa62f9d2..21165a9d520 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts @@ -3,7 +3,7 @@ import { linodeFactory, regionFactory, } from '@linode/utilities'; -import { mockGetLinodeConfigs } from 'support/intercepts/configs'; +import { mockGetLinodeConfig } from 'support/intercepts/configs'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockCreateLinode, @@ -82,7 +82,13 @@ describe('Create Linode with VPCs', () => { linodes: [ { id: mockLinode.id, - interfaces: [{ active: true, config_id: 1, id: mockInterface.id }], + interfaces: [ + { + active: true, + config_id: mockLinodeConfig.id, + id: mockInterface.id, + }, + ], }, ], }; @@ -134,7 +140,7 @@ describe('Create Linode with VPCs', () => { // Confirm that request payload includes VPC interface. expect(expectedVpcInterface['vpc_id']).to.equal(mockVPC.id); - expect(expectedVpcInterface['ipv4']).to.be.an('object').that.is.empty; + expect(expectedVpcInterface['ipv4']).to.deep.equal({}); expect(expectedVpcInterface['subnet_id']).to.equal(mockSubnet.id); expect(expectedVpcInterface['purpose']).to.equal('vpc'); // Confirm that VPC interfaces are always marked as the primary interface @@ -150,13 +156,15 @@ describe('Create Linode with VPCs', () => { mockGetVPC(mockVPC).as('getVPC'); mockGetSubnets(mockVPC.id, [mockUpdatedSubnet]).as('getSubnets'); mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); - mockGetLinodeConfigs(mockLinode.id, [mockLinodeConfig]).as( - 'getLinodeConfigs' - ); + mockGetLinodeConfig({ + config: mockLinodeConfig, + configId: mockLinodeConfig.id, + linodeId: mockLinode.id, + }).as('getLinodeConfig'); cy.visit(`/vpcs/${mockVPC.id}`); cy.findByLabelText(`expand ${mockSubnet.label} row`).click(); - cy.wait('@getLinodeConfigs'); + cy.wait('@getLinodeConfig'); cy.findByTestId(WARNING_ICON_UNRECOMMENDED_CONFIG).should('not.exist'); }); @@ -209,7 +217,13 @@ describe('Create Linode with VPCs', () => { linodes: [ { id: mockLinode.id, - interfaces: [{ active: true, config_id: 1, id: mockInterface.id }], + interfaces: [ + { + active: true, + config_id: mockLinodeConfig.id, + id: mockInterface.id, + }, + ], }, ], }; @@ -314,13 +328,15 @@ describe('Create Linode with VPCs', () => { mockGetVPC(mockVPC).as('getVPC'); mockGetSubnets(mockVPC.id, [mockUpdatedSubnet]).as('getSubnets'); mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); - mockGetLinodeConfigs(mockLinode.id, [mockLinodeConfig]).as( - 'getLinodeConfigs' - ); + mockGetLinodeConfig({ + config: mockLinodeConfig, + configId: mockLinodeConfig.id, + linodeId: mockLinode.id, + }).as('getLinodeConfig'); cy.visit(`/vpcs/${mockVPC.id}`); cy.findByLabelText(`expand ${mockSubnet.label} row`).click(); - cy.wait('@getLinodeConfigs'); + cy.wait('@getLinodeConfig'); cy.findByTestId(WARNING_ICON_UNRECOMMENDED_CONFIG).should('not.exist'); }); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts index 357cb202f2b..aa88a244587 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts @@ -3,8 +3,10 @@ */ import { + grantsFactory, linodeFactory, linodeTypeFactory, + profileFactory, regionFactory, } from '@linode/utilities'; import { authenticate } from 'support/api/authentication'; @@ -30,12 +32,7 @@ import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; import { skip } from 'support/util/skip'; -import { - accountFactory, - accountUserFactory, - grantsFactory, - profileFactory, -} from 'src/factories'; +import { accountFactory, accountUserFactory } from 'src/factories'; let username: string; diff --git a/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts b/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts index e496a549c68..0d651f791be 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts @@ -2,6 +2,7 @@ import { linodeConfigInterfaceFactory, linodeConfigInterfaceFactoryWithVPC, linodeFactory, + regionFactory, } from '@linode/utilities'; import { VLANFactory, @@ -38,7 +39,7 @@ import { cleanUp } from 'support/util/cleanup'; import { fetchAllKernels, findKernelById } from 'support/util/kernels'; import { createTestLinode, fetchLinodeConfigs } from 'support/util/linodes'; import { randomIp, randomLabel, randomNumber } from 'support/util/random'; -import { chooseRegion, getRegionById } from 'support/util/regions'; +import { chooseRegion } from 'support/util/regions'; import { LINODE_UNREACHABLE_HELPER_TEXT, @@ -459,7 +460,12 @@ describe('Linode Config management', () => { }); describe('Mocked', () => { - const region: Region = getRegionById('us-southeast'); + const region: Region = regionFactory.build({ + capabilities: ['Linodes'], + country: 'us', + id: 'us-southeast', + }); + const mockKernel = kernelFactory.build(); const mockVPC = vpcFactory.build({ id: randomNumber(), diff --git a/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts b/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts index 74a9894178b..eee8a2f9ba6 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts @@ -1,15 +1,17 @@ -import { linodeFactory } from '@linode/utilities'; import { linodeInterfaceFactoryPublic, linodeInterfaceFactoryVPC, } from '@linode/utilities'; +import { linodeFactory } from '@linode/utilities'; import { + accountFactory, firewallDeviceFactory, firewallFactory, ipAddressFactory, subnetFactory, vpcFactory, } from '@src/factories'; +import { mockGetAccount } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockAddFirewallDevice, @@ -66,6 +68,7 @@ describe('IP Addresses', () => { public: [ipAddress], reserved: [], shared: [], + vpc: [], }, ipv6: { global: [_ipv6Range], @@ -248,6 +251,11 @@ describe('Firewalls', () => { describe('Linode Interfaces', () => { beforeEach(() => { + mockGetAccount( + accountFactory.build({ + capabilities: ['Linode Interfaces'], + }) + ); mockAppendFeatureFlags({ linodeInterfaces: { enabled: true }, }); @@ -376,7 +384,9 @@ describe('Linode Interfaces', () => { // Select a Subnet ui.autocomplete.findByLabel('Subnet').click(); - ui.autocompletePopper.findByTitle(selectedSubnet.label).click(); + ui.autocompletePopper + .findByTitle(`${selectedSubnet.label} (${selectedSubnet.ipv4})`) + .click(); // Verify the error goes away cy.findByText('Subnet is required.').should('not.exist'); @@ -390,9 +400,9 @@ describe('Linode Interfaces', () => { const requestPayload = xhr.request.body; // Confirm that request payload includes VPC interface only - expect(requestPayload['public']).to.be.null; + expect(requestPayload['public']).to.equal(null); expect(requestPayload['vpc']['subnet_id']).to.equal(selectedSubnet.id); - expect(requestPayload['vlan']).to.null; + expect(requestPayload['vlan']).to.equal(null); }); ui.toast.assertMessage('Successfully added network interface.'); diff --git a/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts b/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts index 07ef3c82d45..fb0becfb575 100644 --- a/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts @@ -149,7 +149,9 @@ describe('displays linode plans panel based on availability', () => { cy.wait(['@getRegions', '@getLinodeTypes']); ui.regionSelect.find().click(); - ui.regionSelect.findItemByRegionLabel(mockRegions[0].label).click(); + ui.regionSelect + .findItemByRegionLabel(mockRegions[0].label, mockRegions) + .click(); cy.wait(['@getRegionAvailability']); @@ -245,7 +247,9 @@ describe('displays kubernetes plans panel based on availability', () => { cy.wait(['@getRegions', '@getLinodeTypes']); ui.regionSelect.find().click(); - ui.regionSelect.findItemByRegionLabel(mockRegions[0].label).click(); + ui.regionSelect + .findItemByRegionLabel(mockRegions[0].label, mockRegions) + .click(); cy.wait(['@getRegionAvailability']); @@ -381,7 +385,9 @@ describe('displays specific linode plans for GPU', () => { cy.visitWithLogin('/linodes/create'); cy.wait(['@getRegions', '@getLinodeTypes', '@getFeatureFlags']); ui.regionSelect.find().click(); - ui.regionSelect.findItemByRegionLabel(mockRegions[0].label).click(); + ui.regionSelect + .findItemByRegionLabel(mockRegions[0].label, mockRegions) + .click(); // GPU tab // Should display two separate tables @@ -429,7 +435,9 @@ describe('displays specific kubernetes plans for GPU', () => { cy.visitWithLogin('/kubernetes/create'); cy.wait(['@getRegions', '@getLinodeTypes', '@getFeatureFlags']); ui.regionSelect.find().click(); - ui.regionSelect.findItemByRegionLabel(mockRegions[0].label).click(); + ui.regionSelect + .findItemByRegionLabel(mockRegions[0].label, mockRegions) + .click(); // GPU tab // Should display two separate tables @@ -535,7 +543,9 @@ describe('Linode Accelerated plans', () => { ]); ui.regionSelect.find().click(); - ui.regionSelect.findItemByRegionLabel(mockRegions[0].label).click(); + ui.regionSelect + .findItemByRegionLabel(mockRegions[0].label, mockRegions) + .click(); cy.findByText('Accelerated').click(); cy.get(linodePlansPanel).within(() => { @@ -588,7 +598,9 @@ describe('Linode Accelerated plans', () => { ]); ui.regionSelect.find().click(); - ui.regionSelect.findItemByRegionLabel(mockRegions[0].label).click(); + ui.regionSelect + .findItemByRegionLabel(mockRegions[0].label, mockRegions) + .click(); cy.wait(['@getRegionAvailability']); diff --git a/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts index e3a4d5fa827..fb20dcb2627 100644 --- a/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts @@ -103,6 +103,7 @@ const submitRebuild = () => { }; // Error message that is displayed when desired password is not strong enough. +// eslint-disable-next-line sonarjs/no-hardcoded-passwords const passwordComplexityError = 'Password does not meet strength requirement.'; authenticate(); @@ -176,7 +177,7 @@ describe('rebuild linode', () => { * - Confirms that a Linode can be rebuilt using a Community StackScript. */ it('rebuilds a linode from Community StackScript', () => { - cy.tag('method:e2e'); + cy.tag('method:e2e', 'env:stackScripts'); const stackScriptId = 443929; const stackScriptName = 'OpenLiteSpeed-WordPress'; const image = 'AlmaLinux 9'; @@ -404,7 +405,7 @@ describe('rebuild linode', () => { cy.wait('@rebuildLinode').then((xhr) => { // Confirm that metadata is NOT in the payload. // If we omit metadata from the payload, the API will reuse previously provided userdata. - expect(xhr.request.body.metadata).to.be.undefined; + expect(xhr.request.body.metadata).to.equal(undefined); // Verify other expected values are in the request expect(xhr.request.body.image).to.equal(image.id); diff --git a/packages/manager/cypress/e2e/core/linodes/smoke-delete-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/smoke-delete-linode.spec.ts index 61ecc709e97..efc21c6ca78 100644 --- a/packages/manager/cypress/e2e/core/linodes/smoke-delete-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/smoke-delete-linode.spec.ts @@ -7,6 +7,7 @@ import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; import { createTestLinode } from 'support/util/linodes'; import { randomLabel } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; import type { Linode } from '@linode/api-v4'; @@ -69,6 +70,7 @@ describe('delete linode', () => { it('deletes linode from linode details page', () => { const linodeCreatePayload = createLinodeRequestFactory.build({ label: randomLabel(), + region: chooseRegion().id, }); cy.defer(() => createTestLinode(linodeCreatePayload)).then((linode) => { // catch delete request @@ -114,6 +116,7 @@ describe('delete linode', () => { it('deletes linode from setting tab in linode details page', () => { const linodeCreatePayload = createLinodeRequestFactory.build({ label: randomLabel(), + region: chooseRegion().id, }); cy.defer(() => createTestLinode(linodeCreatePayload)).then((linode) => { // catch delete request @@ -163,6 +166,7 @@ describe('delete linode', () => { it('deletes linode from linode landing page', () => { const linodeCreatePayload = createLinodeRequestFactory.build({ label: randomLabel(), + region: chooseRegion().id, }); cy.defer(() => createTestLinode(linodeCreatePayload)).then((linode) => { // catch delete request @@ -211,10 +215,16 @@ describe('delete linode', () => { const createTwoLinodes = async (): Promise<[Linode, Linode]> => { return Promise.all([ createTestLinode( - createLinodeRequestFactory.build({ label: randomLabel() }) + createLinodeRequestFactory.build({ + label: randomLabel(), + region: chooseRegion().id, + }) ), createTestLinode( - createLinodeRequestFactory.build({ label: randomLabel() }) + createLinodeRequestFactory.build({ + label: randomLabel(), + region: chooseRegion().id, + }) ), ]); }; diff --git a/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts b/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts index b67e42ff902..aec60223ec5 100644 --- a/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts @@ -1,9 +1,12 @@ /* eslint-disable sonarjs/no-duplicate-string */ -import { linodeFactory } from '@linode/utilities'; -import { profileFactory, userPreferencesFactory } from '@src/factories'; +import { + grantsFactory, + linodeFactory, + profileFactory, + userPreferencesFactory, +} from '@linode/utilities'; import { accountSettingsFactory } from '@src/factories/accountSettings'; import { accountUserFactory } from '@src/factories/accountUsers'; -import { grantsFactory } from '@src/factories/grants'; import { makeResourcePage } from '@src/mocks/serverHandlers'; import { authenticate } from 'support/api/authentication'; import { mockGetUser } from 'support/intercepts/account'; @@ -28,15 +31,15 @@ import { chooseRegion, getRegionById } from 'support/util/regions'; import type { Linode } from '@linode/api-v4'; -const mockLinodes = new Array(5).fill(null).map( - (_item: null, index: number): Linode => { +const mockLinodes = new Array(5) + .fill(null) + .map((_item: null, index: number): Linode => { return linodeFactory.build({ label: `Linode ${index}`, region: chooseRegion().id, tags: [index % 2 == 0 ? 'even' : 'odd', 'nums'], }); - } -); + }); const mockLinodesData = makeResourcePage(mockLinodes); @@ -570,15 +573,15 @@ describe('linode landing checks for empty state', () => { describe('linode landing checks for non-empty state with restricted user', () => { beforeEach(() => { // Mock setup to display the Linode landing page in an non-empty state - const mockLinodes: Linode[] = new Array(1).fill(null).map( - (_item: null, index: number): Linode => { + const mockLinodes: Linode[] = new Array(1) + .fill(null) + .map((_item: null, index: number): Linode => { return linodeFactory.build({ label: `Linode ${index}`, region: chooseRegion().id, tags: [index % 2 == 0 ? 'even' : 'odd', 'nums'], }); - } - ); + }); mockGetLinodes(mockLinodes).as('getLinodes'); diff --git a/packages/manager/cypress/e2e/core/managed/managed-navigation.spec.ts b/packages/manager/cypress/e2e/core/managed/managed-navigation.spec.ts index 9df4cefa15e..07ea0214e64 100644 --- a/packages/manager/cypress/e2e/core/managed/managed-navigation.spec.ts +++ b/packages/manager/cypress/e2e/core/managed/managed-navigation.spec.ts @@ -2,6 +2,7 @@ * @file Integration tests for Managed navigation. */ +import { userPreferencesFactory } from '@linode/utilities'; import { managedAccount, nonManagedAccount, @@ -26,7 +27,6 @@ import { managedIssueFactory, monitorFactory, } from 'src/factories/managed'; -import { userPreferencesFactory } from 'src/factories/profile'; import type { UserPreferences } from '@linode/api-v4'; diff --git a/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts index 78dd831ccd1..5a4c4b4ace3 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts @@ -1,13 +1,12 @@ /** * @file Cypress integration tests for OBJ enrollment and cancellation. */ -import { regionFactory } from '@linode/utilities'; +import { profileFactory, regionFactory } from '@linode/utilities'; import { accountFactory, accountSettingsFactory, objectStorageClusterFactory, objectStorageKeyFactory, - profileFactory, } from '@src/factories'; import { mockGetAccount, @@ -37,7 +36,8 @@ import type { // under different circumstances. const objNotes = { // Information regarding the Object Storage cancellation process. - cancellationExplanation: /To discontinue billing, you.*ll need to cancel Object Storage in your Account Settings./, + cancellationExplanation: + /To discontinue billing, you.*ll need to cancel Object Storage in your Account Settings./, // Link to further DC-specific pricing information. dcPricingLearnMoreNote: 'Learn more about pricing and specifications.', diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts index 71d45be1a66..6a014990a23 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts @@ -100,9 +100,7 @@ describe('object storage end-to-end tests', () => { cy.findByLabelText('Content is loading').should('not.exist'); }); - ui.entityHeader.find().within(() => { - ui.button.findByTitle('Create Bucket').should('be.visible').click(); - }); + ui.button.findByTitle('Create Bucket').should('be.visible').click(); ui.drawer .findByTitle('Create Bucket') @@ -170,31 +168,31 @@ describe('object storage end-to-end tests', () => { interceptUpdateBucketAccess(bucketLabel, bucketCluster).as( 'updateBucketAccess' ); - }); - // Navigate to new bucket page, upload and delete an object. - cy.visitWithLogin(bucketAccessPage); + // Navigate to new bucket page, upload and delete an object. + cy.visitWithLogin(bucketAccessPage); - cy.wait('@getBucketAccess'); + cy.wait('@getBucketAccess'); - // Make object public, confirm it can be accessed. - cy.findByLabelText('Access Control List (ACL)') - .should('be.visible') - .should('not.have.value', 'Loading access...') - .should('have.value', 'Private') - .click(); - cy.focused().type('Public Read'); + // Make object public, confirm it can be accessed. + cy.findByLabelText('Access Control List (ACL)') + .should('be.visible') + .should('not.have.value', 'Loading access...') + .should('have.value', 'Private') + .click(); + cy.focused().type('Public Read'); - ui.autocompletePopper - .findByTitle('Public Read') - .should('be.visible') - .click(); + ui.autocompletePopper + .findByTitle('Public Read') + .should('be.visible') + .click(); - ui.button.findByTitle('Save').should('be.visible').click(); + ui.button.findByTitle('Save').should('be.visible').click(); - // TODO Confirm that outgoing API request contains expected values. - cy.wait('@updateBucketAccess'); + // TODO Confirm that outgoing API request contains expected values. + cy.wait('@updateBucketAccess'); - cy.findByText('Bucket access updated successfully.'); + cy.findByText('Bucket access updated successfully.'); + }); }); }); diff --git a/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-access-keys-gen2.spec.ts b/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-access-keys-gen2.spec.ts index 1141986b61b..e601bedd47a 100644 --- a/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-access-keys-gen2.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-access-keys-gen2.spec.ts @@ -1,3 +1,4 @@ +import { profileFactory } from '@linode/utilities'; import { mockGetAccount } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetAccessKeys } from 'support/intercepts/object-storage'; @@ -5,7 +6,6 @@ import { mockGetProfile } from 'support/intercepts/profile'; import { ui } from 'support/ui'; import { accountFactory, objectStorageKeyFactory } from 'src/factories'; -import { profileFactory } from 'src/factories/profile'; describe('Object Storage gen2 access keys tests', () => { /** @@ -48,8 +48,8 @@ describe('Object Storage gen2 access keys tests', () => { mockGetAccessKeys([mockAccessKey1, mockAccessKey2]).as( 'getObjectStorageAccessKeys' - ), - cy.visitWithLogin('/object-storage/access-keys'); + ); + cy.visitWithLogin('/object-storage/access-keys'); cy.wait(['@getFeatureFlags', '@getAccount', '@getObjectStorageAccessKeys']); diff --git a/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-create-gen2.spec.ts b/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-create-gen2.spec.ts index 02d531df972..5592400f299 100644 --- a/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-create-gen2.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-create-gen2.spec.ts @@ -1,4 +1,4 @@ -import { regionFactory } from '@linode/utilities'; +import { profileFactory, regionFactory } from '@linode/utilities'; import { mockGetAccount } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { @@ -21,7 +21,6 @@ import { objectStorageBucketFactoryGen2, objectStorageEndpointsFactory, } from 'src/factories'; -import { profileFactory } from 'src/factories/profile'; import type { ACLType, ObjectStorageEndpoint } from '@linode/api-v4'; diff --git a/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-object-gen2.spec.ts b/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-object-gen2.spec.ts index 6d10cf1b60a..ffe4e804298 100644 --- a/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-object-gen2.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-object-gen2.spec.ts @@ -413,7 +413,7 @@ describe('Object Storage Gen2 bucket object tests', () => { cy.findByText(mockRegions[0].label).should('be.visible'); }); // warning message - cy.findByTestId('notice-warning-important').within(() => { + cy.findByTestId('notice-warning').within(() => { cy.contains( `There was an error loading buckets in ${mockRegions[1].label}` ); @@ -463,7 +463,7 @@ describe('Object Storage Gen2 bucket object tests', () => { // table with retrieved bucket cy.get('table tbody tr').should('have.length', 1); // warning message - cy.findByTestId('notice-warning-important').within(() => { + cy.findByTestId('notice-warning').within(() => { cy.contains( 'There was an error loading buckets in the following regions:' ); diff --git a/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-create-multicluster.spec.ts b/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-create-multicluster.spec.ts index 3c597b9def5..d28f24324a9 100644 --- a/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-create-multicluster.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-create-multicluster.spec.ts @@ -65,9 +65,7 @@ describe('Object Storage Multicluster Bucket create', () => { cy.visitWithLogin('/object-storage'); cy.wait(['@getRegions', '@getBuckets']); - ui.entityHeader.find().within(() => { - ui.button.findByTitle('Create Bucket').should('be.visible').click(); - }); + ui.button.findByTitle('Create Bucket').should('be.visible').click(); ui.drawer .findByTitle('Create Bucket') @@ -122,7 +120,7 @@ describe('Object Storage Multicluster Bucket create', () => { // property in its payload when creating a bucket. cy.wait('@createBucket').then((xhr) => { const body = xhr.request.body; - expect(body.cluster).to.be.undefined; + expect(body.cluster).to.eq(undefined); expect(body.region).to.eq(mockRegionWithObj.id); }); diff --git a/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-details-multicluster.spec.ts b/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-details-multicluster.spec.ts index 253b54a4596..137f3458ead 100644 --- a/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-details-multicluster.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-details-multicluster.spec.ts @@ -1,7 +1,11 @@ import { regionFactory } from '@linode/utilities'; import { mockGetAccount } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; -import { mockGetBucket } from 'support/intercepts/object-storage'; +import { + mockGetBucket, + mockGetBucketObjects, + mockGetBuckets, +} from 'support/intercepts/object-storage'; import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; import { randomLabel } from 'support/util/random'; @@ -38,6 +42,8 @@ describe('Object Storage Multicluster Bucket Details Tabs', () => { const { label } = mockBucket; mockGetBucket(label, mockRegion.id); + mockGetBuckets([mockBucket]); + mockGetBucketObjects(label, mockRegion.id, []); mockGetRegions([mockRegion]); cy.visitWithLogin( diff --git a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts index c1e069cec3f..67f44a6b5a1 100644 --- a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts +++ b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts @@ -20,7 +20,7 @@ import type { StackScript } from '@linode/api-v4'; describe('OneClick Apps (OCA)', () => { it('Lists all the OneClick Apps', () => { - cy.tag('method:e2e'); + cy.tag('method:e2e', 'env:marketplaceApps'); interceptGetStackScripts().as('getStackScripts'); cy.visitWithLogin(`/linodes/create?type=One-Click`); @@ -57,7 +57,7 @@ describe('OneClick Apps (OCA)', () => { }); it('Can view app details of a marketplace app', () => { - cy.tag('method:e2e'); + cy.tag('method:e2e', 'env:marketplaceApps'); interceptGetStackScripts().as('getStackScripts'); cy.visitWithLogin(`/linodes/create?type=One-Click`); @@ -180,7 +180,7 @@ describe('OneClick Apps (OCA)', () => { cy.findByText('New apps').should('be.visible'); // Check that the app is listed and select it - cy.get('[data-qa-selection-card="true"]').should('have.length', 2); + // The app may be listed 2 or 3 times. cy.findAllByText(stackscript.label).first().should('be.visible').click(); }); @@ -249,8 +249,8 @@ describe('OneClick Apps (OCA)', () => { }); // leave test disabled by default - xit('Validate the summaries of all the OneClick Apps', () => { - cy.tag('method:e2e'); + it.skip('Validate the summaries of all the OneClick Apps', () => { + cy.tag('method:e2e', 'env:marketplaceApps'); interceptGetStackScripts().as('getStackScripts'); cy.visitWithLogin(`/linodes/create?type=One-Click`); diff --git a/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts b/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts index 1f5383399ad..e3d328b43b3 100644 --- a/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts +++ b/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts @@ -1,11 +1,10 @@ +import { grantsFactory, profileFactory } from '@linode/utilities'; import { accountFactory, appTokenFactory, paymentMethodFactory, - profileFactory, } from '@src/factories'; import { accountUserFactory } from '@src/factories/accountUsers'; -import { grantsFactory } from '@src/factories/grants'; import { DateTime } from 'luxon'; import { interceptGetInvoices, diff --git a/packages/manager/cypress/e2e/core/parentChild/token-expiration.spec.ts b/packages/manager/cypress/e2e/core/parentChild/token-expiration.spec.ts index 00b1652fa2a..18227d3bce4 100644 --- a/packages/manager/cypress/e2e/core/parentChild/token-expiration.spec.ts +++ b/packages/manager/cypress/e2e/core/parentChild/token-expiration.spec.ts @@ -1,3 +1,4 @@ +import { profileFactory } from '@linode/utilities'; import { DateTime } from 'luxon'; import { mockGetAccount, @@ -8,11 +9,7 @@ import { mockGetProfile } from 'support/intercepts/profile'; import { ui } from 'support/ui'; import { randomLabel, randomString } from 'support/util/random'; -import { - accountFactory, - accountUserFactory, - profileFactory, -} from 'src/factories'; +import { accountFactory, accountUserFactory } from 'src/factories'; const mockChildAccount = accountFactory.build({ company: 'Partner Company', diff --git a/packages/manager/cypress/e2e/core/parentChild/token-scopes.spec.ts b/packages/manager/cypress/e2e/core/parentChild/token-scopes.spec.ts index 48ab9549bc6..5547bd1a262 100644 --- a/packages/manager/cypress/e2e/core/parentChild/token-scopes.spec.ts +++ b/packages/manager/cypress/e2e/core/parentChild/token-scopes.spec.ts @@ -1,8 +1,5 @@ -import { - accountFactory, - appTokenFactory, - profileFactory, -} from '@src/factories'; +import { profileFactory } from '@linode/utilities'; +import { accountFactory, appTokenFactory } from '@src/factories'; import { accountUserFactory } from '@src/factories/accountUsers'; import { DateTime } from 'luxon'; import { diff --git a/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscripts.spec.ts index d6343ceeec4..2407527532e 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscripts.spec.ts @@ -190,7 +190,7 @@ describe('Community Stackscripts integration tests', () => { * - Confirms that pagination works as expected. */ it('pagination works with infinite scrolling', () => { - cy.tag('method:e2e'); + cy.tag('method:e2e', 'env:stackScripts'); interceptGetStackScripts().as('getStackScripts'); // Fetch all public Images to later use while filtering StackScripts. @@ -236,7 +236,7 @@ describe('Community Stackscripts integration tests', () => { * - Confirms that search can filter the expected results. */ it('search function filters results correctly', () => { - cy.tag('method:e2e'); + cy.tag('method:e2e', 'env:stackScripts'); const stackScript = mockStackScripts[0]; interceptGetStackScripts().as('getStackScripts'); @@ -267,6 +267,7 @@ describe('Community Stackscripts integration tests', () => { * - Confirms that the deployment flow works. */ it('deploys a new linode as expected', () => { + cy.tag('method:e2e', 'env:stackScripts'); const stackScriptId = '37239'; const stackScriptName = 'setup-ipsec-vpn'; const sharedKey = randomString(); diff --git a/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts index 8a12ff4f8ee..0af22de91f6 100644 --- a/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts @@ -1,9 +1,9 @@ -import { createLinodeRequestFactory } from '@linode/utilities'; import { - accountUserFactory, + createLinodeRequestFactory, grantsFactory, profileFactory, -} from '@src/factories'; +} from '@linode/utilities'; +import { accountUserFactory } from '@src/factories'; import { authenticate } from 'support/api/authentication'; import { entityTag } from 'support/constants/cypress'; import { mockGetUser } from 'support/intercepts/account'; diff --git a/packages/manager/cypress/e2e/core/volumes/search-volumes.spec.ts b/packages/manager/cypress/e2e/core/volumes/search-volumes.spec.ts index 63887775207..34cfd8678b8 100644 --- a/packages/manager/cypress/e2e/core/volumes/search-volumes.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/search-volumes.spec.ts @@ -46,8 +46,8 @@ describe('Search Volumes', () => { // Search for the first volume by label, confirm it's the only one shown. cy.findByPlaceholderText('Search Volumes').type(volume1.label); - expect(cy.findByText(volume1.label).should('be.visible')); - expect(cy.findByText(volume2.label).should('not.exist')); + cy.findByText(volume1.label).should('be.visible'); + cy.findByText(volume2.label).should('not.exist'); // Clear search, confirm both volumes are shown. cy.findByTestId('clear-volumes-search').click(); diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts index dcc5323773a..812427afbaf 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts @@ -4,13 +4,14 @@ import { linodeFactory, } from '@linode/utilities'; import { linodeConfigFactory, subnetFactory, vpcFactory } from '@src/factories'; -import { mockGetLinodeConfigs } from 'support/intercepts/configs'; +import { mockGetLinodeConfig } from 'support/intercepts/configs'; import { mockGetLinodeDetails } from 'support/intercepts/linodes'; import { mockCreateSubnet, mockDeleteSubnet, mockDeleteVPC, mockEditSubnet, + mockGetSubnet, mockGetSubnets, mockGetVPC, mockGetVPCs, @@ -177,6 +178,7 @@ describe('VPC details page', () => { cy.findByText(mockVPC.label).should('be.visible'); cy.findByText('Subnets (1)').should('be.visible'); cy.findByText(mockSubnet.label).should('be.visible'); + mockGetSubnet(mockVPC.id, mockSubnet.id, mockSubnet); // edit a subnet const mockEditedSubnet = subnetFactory.build({ @@ -201,6 +203,7 @@ describe('VPC details page', () => { .should('be.visible') .click(); ui.actionMenuItem.findByTitle('Edit').should('be.visible').click(); + mockGetSubnet(mockVPC.id, mockEditedSubnet.id, mockEditedSubnet); ui.drawer .findByTitle('Edit Subnet') @@ -274,6 +277,7 @@ describe('VPC details page', () => { const linodeRegion = chooseRegion({ capabilities: ['VPCs'] }); const mockInterfaceId = randomNumber(); + const mockConfigId = randomNumber(); const mockLinode = linodeFactory.build({ id: randomNumber(), label: randomLabel(), @@ -287,7 +291,13 @@ describe('VPC details page', () => { linodes: [ { id: mockLinode.id, - interfaces: [{ active: true, id: mockInterfaceId }], + interfaces: [ + { + active: true, + config_id: mockConfigId, + id: mockInterfaceId, + }, + ], }, ], }); @@ -301,25 +311,29 @@ describe('VPC details page', () => { const mockInterface = linodeConfigInterfaceFactoryWithVPC.build({ active: true, + id: mockInterfaceId, primary: true, subnet_id: mockSubnet.id, vpc_id: mockVPC.id, }); const mockLinodeConfig = linodeConfigFactory.build({ + id: mockConfigId, interfaces: [mockInterface], }); mockGetVPC(mockVPC).as('getVPC'); mockGetSubnets(mockVPC.id, [mockSubnet]).as('getSubnets'); mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); - mockGetLinodeConfigs(mockLinode.id, [mockLinodeConfig]).as( - 'getLinodeConfigs' - ); + mockGetLinodeConfig({ + config: mockLinodeConfig, + configId: mockLinodeConfig.id, + linodeId: mockLinode.id, + }).as('getLinodeConfig'); cy.visitWithLogin(`/vpcs/${mockVPC.id}`); cy.findByLabelText(`expand ${mockSubnet.label} row`).click(); - cy.wait('@getLinodeConfigs'); + cy.wait('@getLinodeConfig'); cy.findByTestId(WARNING_ICON_UNRECOMMENDED_CONFIG).should('not.exist'); }); @@ -330,6 +344,7 @@ describe('VPC details page', () => { const linodeRegion = chooseRegion({ capabilities: ['VPCs'] }); const mockInterfaceId = randomNumber(); + const mockConfigId = randomNumber(); const mockLinode = linodeFactory.build({ id: randomNumber(), label: randomLabel(), @@ -343,7 +358,13 @@ describe('VPC details page', () => { linodes: [ { id: mockLinode.id, - interfaces: [{ active: true, id: mockInterfaceId }], + interfaces: [ + { + active: true, + config_id: mockConfigId, + id: mockInterfaceId, + }, + ], }, ], }); @@ -364,19 +385,22 @@ describe('VPC details page', () => { }); const mockLinodeConfig = linodeConfigFactory.build({ + id: mockConfigId, interfaces: [mockInterface], }); mockGetVPC(mockVPC).as('getVPC'); mockGetSubnets(mockVPC.id, [mockSubnet]).as('getSubnets'); mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); - mockGetLinodeConfigs(mockLinode.id, [mockLinodeConfig]).as( - 'getLinodeConfigs' - ); + mockGetLinodeConfig({ + config: mockLinodeConfig, + configId: mockLinodeConfig.id, + linodeId: mockLinode.id, + }).as('getLinodeConfig'); cy.visitWithLogin(`/vpcs/${mockVPC.id}`); cy.findByLabelText(`expand ${mockSubnet.label} row`).click(); - cy.wait('@getLinodeConfigs'); + cy.wait('@getLinodeConfig'); cy.findByTestId(WARNING_ICON_UNRECOMMENDED_CONFIG).should('not.exist'); }); @@ -387,6 +411,7 @@ describe('VPC details page', () => { const linodeRegion = chooseRegion({ capabilities: ['VPCs'] }); const mockInterfaceId = randomNumber(); + const mockConfigId = randomNumber(); const mockLinode = linodeFactory.build({ id: randomNumber(), label: randomLabel(), @@ -400,7 +425,13 @@ describe('VPC details page', () => { linodes: [ { id: mockLinode.id, - interfaces: [{ active: true, id: mockInterfaceId }], + interfaces: [ + { + active: true, + config_id: mockConfigId, + id: mockInterfaceId, + }, + ], }, ], }); @@ -427,19 +458,22 @@ describe('VPC details page', () => { }); const mockLinodeConfig = linodeConfigFactory.build({ + id: mockConfigId, interfaces: [mockInterface, mockPrimaryInterface], }); mockGetVPC(mockVPC).as('getVPC'); mockGetSubnets(mockVPC.id, [mockSubnet]).as('getSubnets'); mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); - mockGetLinodeConfigs(mockLinode.id, [mockLinodeConfig]).as( - 'getLinodeConfigs' - ); + mockGetLinodeConfig({ + config: mockLinodeConfig, + configId: mockLinodeConfig.id, + linodeId: mockLinode.id, + }).as('getLinodeConfig'); cy.visitWithLogin(`/vpcs/${mockVPC.id}`); cy.findByLabelText(`expand ${mockSubnet.label} row`).click(); - cy.wait('@getLinodeConfigs'); + cy.wait('@getLinodeConfig'); cy.findByTestId(WARNING_ICON_UNRECOMMENDED_CONFIG).should('exist'); }); }); diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts index d52b2d19575..c2e921f6f20 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts @@ -3,6 +3,7 @@ import { MOCK_DELETE_VPC_ERROR, mockDeleteVPC, mockDeleteVPCError, + mockGetVPC, mockGetVPCs, mockUpdateVPC, } from 'support/intercepts/vpc'; @@ -100,6 +101,7 @@ describe('VPC landing page', () => { }; mockGetVPCs([mockVPCs[1]]).as('getVPCs'); + mockGetVPC(mockVPCs[1]).as('getVPC'); mockUpdateVPC(mockVPCs[1].id, mockUpdatedVPC).as('updateVPC'); cy.visitWithLogin('/vpcs'); @@ -165,6 +167,7 @@ describe('VPC landing page', () => { // Delete VPCs Flow mockGetVPCs(mockVPCs).as('getVPCs'); + mockGetVPC(mockVPCs[0]).as('getVPC'); mockDeleteVPC(mockVPCs[0].id).as('deleteVPC'); cy.visitWithLogin('/vpcs'); @@ -254,6 +257,7 @@ describe('VPC landing page', () => { ]; mockGetVPCs(mockVPCs).as('getVPCs'); + mockGetVPC(mockVPCs[0]).as('getVPC'); mockDeleteVPCError(mockVPCs[0].id).as('deleteVPCError'); cy.visitWithLogin('/vpcs'); @@ -302,6 +306,7 @@ describe('VPC landing page', () => { .click(); }); + mockGetVPC(mockVPCs[1]).as('getVPC'); cy.findByText(mockVPCs[1].label) .should('be.visible') .closest('tr') diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts index 840b1d7f09b..10fafa2a777 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts @@ -8,8 +8,8 @@ import { } from '@linode/utilities'; import { linodeConfigFactory, subnetFactory, vpcFactory } from '@src/factories'; import { - vpcAssignLinodeRebootNotice, - vpcUnassignLinodeRebootNotice, + vpcLinodeInterfaceShutDownNotice, + vpcConfigProfileInterfaceRebootNotice, } from 'support/constants/vpc'; import { mockCreateLinodeConfigInterfaces, @@ -19,6 +19,7 @@ import { import { mockGetLinodes } from 'support/intercepts/linodes'; import { mockCreateSubnet, + mockGetSubnet, mockGetSubnets, mockGetVPC, mockGetVPCs, @@ -110,6 +111,8 @@ describe('VPC assign/unassign flows', () => { cy.wait(['@createSubnet', '@getVPC', '@getSubnets', '@getLinodes']); + mockGetSubnet(mockVPC.id, mockSubnet.id, mockSubnet); + // confirm that newly created subnet should now appear on VPC's detail page cy.findByText(mockVPC.label).should('be.visible'); cy.findByText('Subnets (1)').should('be.visible'); @@ -130,8 +133,11 @@ describe('VPC assign/unassign flows', () => { .findByTitle(`Assign Linodes to subnet: ${mockSubnet.label} (0.0.0.0/0)`) .should('be.visible') .within(() => { - // confirm that the user is warned that a reboot is required - cy.findByText(vpcAssignLinodeRebootNotice).should('be.visible'); + // confirm that the user is warned that a reboot / shutdown is required + cy.findByText(vpcLinodeInterfaceShutDownNotice).should('be.visible'); + cy.findByText(vpcConfigProfileInterfaceRebootNotice).should( + 'be.visible' + ); ui.button .findByTitle('Assign Linode') @@ -231,6 +237,8 @@ describe('VPC assign/unassign flows', () => { cy.findByText('Subnets (1)').should('be.visible'); cy.findByText(mockSubnet.label).should('be.visible'); + mockGetSubnet(mockVPC.id, mockSubnet.id, mockSubnet); + // unassign a linode to the subnet ui.actionMenu .findByTitle(`Action menu for Subnet ${mockSubnet.label}`) @@ -248,8 +256,11 @@ describe('VPC assign/unassign flows', () => { ) .should('be.visible') .within(() => { - // confirm that the user is warned that a reboot is required - cy.findByText(vpcUnassignLinodeRebootNotice).should('be.visible'); + // confirm that the user is warned that a reboot / shutdown is required + cy.findByText(vpcLinodeInterfaceShutDownNotice).should('be.visible'); + cy.findByText(vpcConfigProfileInterfaceRebootNotice).should( + 'be.visible' + ); ui.button .findByTitle('Unassign Linodes') @@ -272,7 +283,7 @@ describe('VPC assign/unassign flows', () => { cy.wait('@getLinodeConfigs'); // the select option won't disappear unless click on somewhere else - cy.findByText(vpcUnassignLinodeRebootNotice).click(); + cy.findByText(vpcLinodeInterfaceShutDownNotice).click(); // confirm that unassigned Linode(s) are displayed on the details page cy.findByText('Linodes to be Unassigned from Subnet (1)').should( 'be.visible' @@ -289,7 +300,7 @@ describe('VPC assign/unassign flows', () => { cy.wait('@getLinodeConfigs'); // confirm that unassigned Linode(s) are displayed on the details page - cy.findByText(vpcUnassignLinodeRebootNotice).click(); + cy.findByText(vpcLinodeInterfaceShutDownNotice).click(); cy.findByText('Linodes to be Unassigned from Subnet (2)').should( 'be.visible' ); diff --git a/packages/manager/cypress/support/api/common.ts b/packages/manager/cypress/support/api/common.ts index e9b88d819df..69e9fe4cb6c 100644 --- a/packages/manager/cypress/support/api/common.ts +++ b/packages/manager/cypress/support/api/common.ts @@ -12,12 +12,8 @@ export const apiCheckErrors = ( if (resp.body && resp.body.ERRORARRAY && resp.body.ERRORARRAY.length > 0) { errs = resp.body.ERRORARRAY; } - if (failOnError) { - if (errs) { - expect((errs[0] as any).ERRORMESSAGE).not.to.be.exist; - } else { - expect(!!errs).to.be.false; - } + if (failOnError && !!errs) { + throw new Error('API error!'); } return errs; }; diff --git a/packages/manager/cypress/support/constants/databases.ts b/packages/manager/cypress/support/constants/databases.ts index e34533ba34d..ddece243eda 100644 --- a/packages/manager/cypress/support/constants/databases.ts +++ b/packages/manager/cypress/support/constants/databases.ts @@ -5,11 +5,11 @@ import type { DatabaseEngine, DatabaseType, } from '@linode/api-v4'; -import { randomLabel } from 'support/util/random'; +import { randomIp, randomLabel } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; import { databaseEngineFactory, databaseTypeFactory } from '@src/factories'; -export interface databaseClusterConfiguration { +export interface DatabaseClusterConfiguration { clusterSize: ClusterSize; dbType: Engine; engine: string; @@ -17,6 +17,7 @@ export interface databaseClusterConfiguration { linodeType: string; region: Region; version: string; + ip: string; } /** @@ -325,7 +326,7 @@ export const mockDatabaseNodeTypes: DatabaseType[] = [ ]; // Array of database cluster configurations for which to test creation. -export const databaseConfigurations: databaseClusterConfiguration[] = [ +export const databaseConfigurations: DatabaseClusterConfiguration[] = [ { clusterSize: 1, dbType: 'mysql', @@ -334,6 +335,17 @@ export const databaseConfigurations: databaseClusterConfiguration[] = [ linodeType: 'g6-nanode-1', region: chooseRegion({ capabilities: ['Managed Databases'] }), version: '8', + ip: randomIp(), + }, + { + clusterSize: 2, + dbType: 'mysql', + engine: 'MySQL', + label: randomLabel(), + linodeType: 'g6-dedicated-2', + region: chooseRegion({ capabilities: ['Managed Databases'] }), + version: '8', + ip: '8e61:f9e9:8d40:6e0a:cbff:c97a:2692:827e', }, { clusterSize: 3, @@ -343,6 +355,7 @@ export const databaseConfigurations: databaseClusterConfiguration[] = [ linodeType: 'g6-dedicated-2', region: chooseRegion({ capabilities: ['Managed Databases'] }), version: '5', + ip: '', }, { clusterSize: 3, @@ -352,10 +365,11 @@ export const databaseConfigurations: databaseClusterConfiguration[] = [ linodeType: 'g6-nanode-1', region: chooseRegion({ capabilities: ['Managed Databases'] }), version: '13', + ip: randomIp(), }, ]; -export const databaseConfigurationsResize: databaseClusterConfiguration[] = [ +export const databaseConfigurationsResize: DatabaseClusterConfiguration[] = [ { clusterSize: 3, dbType: 'mysql', @@ -364,6 +378,7 @@ export const databaseConfigurationsResize: databaseClusterConfiguration[] = [ linodeType: 'g6-standard-6', region: chooseRegion({ capabilities: ['Managed Databases'] }), version: '8', + ip: randomIp(), }, { clusterSize: 3, @@ -373,5 +388,6 @@ export const databaseConfigurationsResize: databaseClusterConfiguration[] = [ linodeType: 'g6-dedicated-16', region: chooseRegion({ capabilities: ['Managed Databases'] }), version: '5', + ip: randomIp(), }, ]; diff --git a/packages/manager/cypress/support/constants/user-permissions.ts b/packages/manager/cypress/support/constants/user-permissions.ts index 462f1033fa7..a30b4deef30 100644 --- a/packages/manager/cypress/support/constants/user-permissions.ts +++ b/packages/manager/cypress/support/constants/user-permissions.ts @@ -1,5 +1,6 @@ +import { grantFactory, grantsFactory } from '@linode/utilities'; import { randomLabel } from 'support/util/random'; -import { grantFactory, grantsFactory } from 'src/factories/grants'; + import type { Grants } from '@linode/api-v4'; /** diff --git a/packages/manager/cypress/support/constants/vpc.ts b/packages/manager/cypress/support/constants/vpc.ts index 46b34b0dea7..511fecbd780 100644 --- a/packages/manager/cypress/support/constants/vpc.ts +++ b/packages/manager/cypress/support/constants/vpc.ts @@ -1,7 +1,5 @@ -/** Notice shown to users trying to assign a linode to a VPC. */ -export const vpcAssignLinodeRebootNotice = - 'Assigning a Linode to a subnet requires you to reboot the Linode to update its configuration.'; - -/** Notice shown to users trying to unassign a linode from a VPC. */ -export const vpcUnassignLinodeRebootNotice = - 'Unassigning Linodes from a subnet requires you to reboot the Linodes to update its configuration.'; +/** Copies shown to users when assigning and unassigning a Linode from a VPC */ +export const vpcConfigProfileInterfaceRebootNotice = + 'Linodes with Configuration Profile Interfaces to be rebooted after changes.'; +export const vpcLinodeInterfaceShutDownNotice = + 'Linodes with Linode Interfaces to be shut down and powered on after changes.'; diff --git a/packages/manager/cypress/support/intercepts/cloudpulse.ts b/packages/manager/cypress/support/intercepts/cloudpulse.ts index b2c86d73270..0dc4df75750 100644 --- a/packages/manager/cypress/support/intercepts/cloudpulse.ts +++ b/packages/manager/cypress/support/intercepts/cloudpulse.ts @@ -14,7 +14,6 @@ import { makeResponse } from 'support/util/response'; import type { Alert, CloudPulseMetricsResponse, - CreateAlertDefinitionPayload, Dashboard, MetricDefinition, NotificationChannel, @@ -355,12 +354,12 @@ export const mockGetAlertChannels = ( export const mockCreateAlertDefinition = ( serviceType: string, - createAlertRequest: CreateAlertDefinitionPayload + alert: Alert ): Cypress.Chainable => { return cy.intercept( 'POST', apiMatcher(`/monitor/services/${serviceType}/alert-definitions`), - paginateResponse(createAlertRequest) + makeResponse(alert) ); }; /** diff --git a/packages/manager/cypress/support/intercepts/configs.ts b/packages/manager/cypress/support/intercepts/configs.ts index 3a6396050e9..1671b9227e0 100644 --- a/packages/manager/cypress/support/intercepts/configs.ts +++ b/packages/manager/cypress/support/intercepts/configs.ts @@ -4,9 +4,10 @@ import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; -import { Config } from '@linode/api-v4'; import { makeResponse } from 'support/util/response'; +import type { Config, Interface } from '@linode/api-v4'; + /** * Intercepts GET request to fetch all configs for a given linode. * @@ -102,7 +103,7 @@ export const mockDeleteLinodeConfigInterface = ( * Mocks GET request to retrieve Linode configs. * * @param linodeId - ID of Linode for mocked request. - * @param configs - a list of Linode configswith which to mocked response. + * @param configs - a list of Linode configs with which to mocked response. * * @returns Cypress chainable. */ @@ -117,6 +118,54 @@ export const mockGetLinodeConfigs = ( ); }; +/** + * Mocks GET request to retrieve a Linode Config + * + * @param linodeId - ID of Linode for mocked request. + * @param configId - ID of Config for mocked request. + * @param config - the config with which to mocked response. + * + * @returns Cypress chainable. + */ +export const mockGetLinodeConfig = (inputs: { + config: Config; + configId: number; + linodeId: number; +}): Cypress.Chainable => { + const { config, configId, linodeId } = inputs; + return cy.intercept( + 'GET', + apiMatcher(`linode/instances/${linodeId}/configs/${configId}`), + config + ); +}; + +/** + * Mocks GET request to retrieve an interface of a Linode config + * + * @param linodeId - ID of Linode for mocked request. + * @param configId - ID of Config for mocked request. + * @param linodeId - ID of Config Interface for mocked request. + * @param configInterface - the interface with which to mocked response. + * + * @returns Cypress chainable. + */ +export const mockGetLinodeConfigInterface = (inputs: { + configId: number; + configInterface: Interface; + interfaceId: number; + linodeId: number; +}): Cypress.Chainable => { + const { configId, configInterface, interfaceId, linodeId } = inputs; + return cy.intercept( + 'GET', + apiMatcher( + `linode/instances/${linodeId}/configs/${configId}/interfaces/${interfaceId}` + ), + configInterface + ); +}; + /** * Mocks PUT request to update a linode config. * diff --git a/packages/manager/cypress/support/intercepts/firewalls.ts b/packages/manager/cypress/support/intercepts/firewalls.ts index 490c1fb11bc..cf96b657257 100644 --- a/packages/manager/cypress/support/intercepts/firewalls.ts +++ b/packages/manager/cypress/support/intercepts/firewalls.ts @@ -4,10 +4,12 @@ import { makeErrorResponse } from 'support/util/errors'; import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; +import { makeResponse } from 'support/util/response'; import type { Firewall, FirewallDevice, + FirewallSettings, FirewallTemplate, } from '@linode/api-v4'; @@ -152,12 +154,46 @@ export const mockAddFirewallDevice = ( * * @returns Cypress chainable. */ -export const mockGetTemplate = ( +export const mockGetFirewallTemplate = ( template: FirewallTemplate ): Cypress.Chainable => { return cy.intercept( 'GET', - apiMatcher('networking/firewalls/templates/*'), + apiMatcher(`networking/firewalls/templates/${template.slug}`), template ); }; + +/** + * Intercepts GET request to fetch Firewall templates and mocks response. + * + * @param templates - Array of templates with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetFirewallTemplates = ( + templates: FirewallTemplate[] +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`networking/firewalls/templates*`), + paginateResponse(templates) + ); +}; + +/** + * Intercepts GET request to fetch Firewall settings and mocks response. + * + * @param settings - Firewall settings object with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetFirewallSettings = ( + settings: FirewallSettings +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher('networking/firewalls/settings'), + makeResponse(settings) + ); +}; diff --git a/packages/manager/cypress/support/intercepts/general.ts b/packages/manager/cypress/support/intercepts/general.ts index 3c53386a098..14d8eaa82f8 100644 --- a/packages/manager/cypress/support/intercepts/general.ts +++ b/packages/manager/cypress/support/intercepts/general.ts @@ -59,7 +59,7 @@ export const mockApiMaintenanceMode = (): Cypress.Chainable => { }; /** - * Intercepts all requests to Linode API v4 and mocks an error HTTP response. + * Intercepts all requests to Linode API-v4 and mocks an error HTTP response. * * @param errorCode - HTTP status code to mock. * @param errorMessage - Response error message to mock. @@ -81,3 +81,23 @@ export const mockApiRequestWithError = ( }, }); }; + +/** + * Intercepts all requests to Linode API-v4 and inserts internal user header. + * + * @returns Cypress chainable. + */ +export const mockApiInternalUser = (): Cypress.Chainable => { + return cy.intercept( + { + middleware: true, + url: apiMatcher('**/*'), + }, + (req) => { + // Re-add internal-only header + req.on('response', (res) => { + res.headers['akamai-internal-account'] = '*'; + }); + } + ); +}; diff --git a/packages/manager/cypress/support/intercepts/vpc.ts b/packages/manager/cypress/support/intercepts/vpc.ts index 362b263076c..d2c179b8520 100644 --- a/packages/manager/cypress/support/intercepts/vpc.ts +++ b/packages/manager/cypress/support/intercepts/vpc.ts @@ -132,6 +132,27 @@ export const mockGetSubnets = ( ); }; +/** + * Intercepts GET request to get a specific subnet and mocks response. + * + * @param vpcId - ID of VPC for which to mock response. + * @param subnetId - ID of subnet for which to mock response. + * @param subnet - Subnet for which to mock response + * + * @returns Cypress chainable. + */ +export const mockGetSubnet = ( + vpcId: number, + subnetId: number, + subnet: Subnet +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`vpcs/${vpcId}/subnets/${subnetId}`), + subnet + ); +}; + /** * Intercepts DELETE request to delete a subnet of a VPC and mocks response * diff --git a/packages/manager/cypress/support/setup/test-tagging.ts b/packages/manager/cypress/support/setup/test-tagging.ts index 5dbd48c8405..0d3b0931943 100644 --- a/packages/manager/cypress/support/setup/test-tagging.ts +++ b/packages/manager/cypress/support/setup/test-tagging.ts @@ -19,7 +19,7 @@ const query = Cypress.env('CY_TEST_TAGS') ?? ''; */ Cypress.on('test:before:run', (_test: Test, _runnable: Runnable) => { /* - * Looks for the first command that does not belong in a hook and evalutes tags. + * Looks for the first command that does not belong in a hook and evaluates tags. * * Waiting for the first command to begin executing ensures that test context * is set up and that tags have been assigned to the test. @@ -27,9 +27,16 @@ Cypress.on('test:before:run', (_test: Test, _runnable: Runnable) => { const commandHandler = () => { const context = cy.state('ctx'); if (context && context.test?.type !== 'hook') { - const tags = context?.tags ?? []; + const tags = context?.tags ? [...context.tags] : []; + + // Remove tags from context now that we've read them and can evaluate them. + // This prevents tags from persisting between tests in certain situations. + if (tags.length) { + context.tags = []; + } if (!evaluateQuery(query, tags)) { + // eslint-disable-next-line sonarjs/no-skipped-tests context.skip(); } diff --git a/packages/manager/cypress/support/ui/common.ts b/packages/manager/cypress/support/ui/common.ts index 2dc2ac382d1..77c2debe55c 100644 --- a/packages/manager/cypress/support/ui/common.ts +++ b/packages/manager/cypress/support/ui/common.ts @@ -15,7 +15,11 @@ export const waitForAppLoad = (path = '/', withLogin = true) => { 'getNotifications' ); - withLogin ? cy.visitWithLogin(path) : cy.visit(path); + if (withLogin) { + cy.visitWithLogin(path); + } else { + cy.visit(path); + } cy.wait([ '@getAccount', '@getAccountSettings', diff --git a/packages/manager/cypress/support/util/csv.ts b/packages/manager/cypress/support/util/csv.ts index 16c1388729b..c4cc6581f24 100644 --- a/packages/manager/cypress/support/util/csv.ts +++ b/packages/manager/cypress/support/util/csv.ts @@ -17,6 +17,7 @@ export function parseCsv(csvContent: string): any[] { // Extract the headers from the first line and remove any quotes const headers = lines[0] .split(',') + // eslint-disable-next-line sonarjs/anchor-precedence .map((header) => header.trim().replace(/^"|"$/g, '')); // Map the remaining lines to objects using the headers @@ -30,7 +31,9 @@ export function parseCsv(csvContent: string): any[] { // - Removes the enclosing double quotes from quoted values // - Replaces any escaped double quotes within quoted values with a single double quote const values = line + // eslint-disable-next-line sonarjs/slow-regex .match(/("([^"]|"")*"|[^",\s]+)(?=\s*,|\s*$)/g) + // eslint-disable-next-line sonarjs/anchor-precedence ?.map((value) => value.trim().replace(/^"|"$/g, '').replace(/""/g, '"')); // Create an object to represent the row diff --git a/packages/manager/cypress/support/util/linodes.ts b/packages/manager/cypress/support/util/linodes.ts index 55997e2ca6d..c5c64f68535 100644 --- a/packages/manager/cypress/support/util/linodes.ts +++ b/packages/manager/cypress/support/util/linodes.ts @@ -79,7 +79,7 @@ export const defaultCreateTestLinodeOptions = { * @returns Promise that resolves to the created Linode. */ export const createTestLinode = async ( - createRequestPayload?: Partial | null, + createRequestPayload?: null | Partial, options?: Partial ): Promise => { const resolvedOptions = { @@ -92,29 +92,32 @@ export const createTestLinode = async ( regionId = chooseRegion().id; } - const securityMethodPayload: Partial = await (async () => { - switch (resolvedOptions.securityMethod) { - case 'firewall': - default: - const firewall = await findOrCreateDependencyFirewall(); - return { - firewall_id: firewall.id, - }; - - case 'vlan_no_internet': - const vlanConfig = linodeVlanNoInternetConfig; - const vlanLabel = await findOrCreateDependencyVlan(regionId); - vlanConfig[0].label = vlanLabel; - return { - interfaces: vlanConfig, - }; - - case 'powered_off': - return { - booted: false, - }; - } - })(); + const securityMethodPayload: Partial = + await (async () => { + switch (resolvedOptions.securityMethod) { + case 'firewall': + const firewall = await findOrCreateDependencyFirewall(); + return { + firewall_id: firewall.id, + }; + + case 'powered_off': + return { + booted: false, + }; + + case 'vlan_no_internet': + const vlanConfig = linodeVlanNoInternetConfig; + const vlanLabel = await findOrCreateDependencyVlan(regionId); + vlanConfig[0].label = vlanLabel; + return { + interfaces: vlanConfig, + }; + + default: + return {}; + } + })(); const resolvedCreatePayload = { ...createLinodeRequestFactory.build({ @@ -149,7 +152,7 @@ export const createTestLinode = async ( ); } - // eslint-disable-next-line + // eslint-disable-next-line @linode/cloud-manager/no-createLinode const linode = await createLinode(resolvedCreatePayload); // Wait for disks to become available if `waitForDisks` option is set. diff --git a/packages/manager/cypress/support/util/regions.ts b/packages/manager/cypress/support/util/regions.ts index 26b80b4ace0..d48677e5c05 100644 --- a/packages/manager/cypress/support/util/regions.ts +++ b/packages/manager/cypress/support/util/regions.ts @@ -236,6 +236,11 @@ interface ChooseRegionOptions { * Regions from which to choose. If unspecified, Regions exposed by the API will be used. */ regions?: Region[]; + + /** + * Array of region IDs to exclude from results, in addition to `disallowedRegionIds` regions. + */ + exclude?: string[]; } /** @@ -289,6 +294,10 @@ const resolveSearchRegions = ( ): Region[] => { const requiredCapabilities = options?.capabilities ?? []; const overrideRegion = getOverrideRegion(); + const allDisallowedRegionIds = [ + ...disallowedRegionIds, + ...(options?.exclude ?? []), + ]; // If the user has specified an override region for this run, it takes precedent // over any other specified criteria. @@ -303,7 +312,7 @@ const resolveSearchRegions = ( )}` ); } - if (disallowedRegionIds.includes(overrideRegion.id)) { + if (allDisallowedRegionIds.includes(overrideRegion.id)) { throw new Error( `Override region ${overrideRegion.id} (${overrideRegion.label}) is disallowed for testing due to capacity limitations.` ); @@ -314,7 +323,7 @@ const resolveSearchRegions = ( const capableRegions = regionsWithCapabilities( options?.regions ?? regions, requiredCapabilities - ).filter((region: Region) => !disallowedRegionIds.includes(region.id)); + ).filter((region: Region) => !allDisallowedRegionIds.includes(region.id)); if (!capableRegions.length) { throw new Error( diff --git a/packages/manager/cypress/support/util/tag.ts b/packages/manager/cypress/support/util/tag.ts index db105961963..203291be8f0 100644 --- a/packages/manager/cypress/support/util/tag.ts +++ b/packages/manager/cypress/support/util/tag.ts @@ -9,7 +9,10 @@ const queryRegex = /(?:-|\+)?([^\s]+)/g; export type TestTag = // Environment-related tags. // Used to identify tests where certain environment-specific features are required. + | 'env:marketplaceApps' + | 'env:multipleRegions' | 'env:premiumPlans' + | 'env:stackScripts' // Feature-related tags. // Used to identify tests which deal with a certain feature or features. diff --git a/packages/manager/eslint.config.js b/packages/manager/eslint.config.js new file mode 100644 index 00000000000..a1c7557a85d --- /dev/null +++ b/packages/manager/eslint.config.js @@ -0,0 +1,451 @@ +import js from '@eslint/js'; +import eslint from '@eslint/js'; +import linodeRules from '@linode/eslint-plugin-cloud-manager/dist/index.js'; +import * as tsParser from '@typescript-eslint/parser'; +import eslintConfigPrettier from 'eslint-config-prettier'; +import pluginCypress from 'eslint-plugin-cypress/flat'; +import jsxA11y from 'eslint-plugin-jsx-a11y'; +import perfectionist from 'eslint-plugin-perfectionist'; +import prettier from 'eslint-plugin-prettier'; +import react from 'eslint-plugin-react'; +import reactHooks from 'eslint-plugin-react-hooks'; +import sonarjs from 'eslint-plugin-sonarjs'; +import testingLibrary from 'eslint-plugin-testing-library'; +import xss from 'eslint-plugin-xss'; +import { defineConfig } from 'eslint/config'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; + +export const baseConfig = [ + // 1. Ignores + { + ignores: [ + '**/node_modules/*', + '**/build/*', + '**/dist/*', + '**/lib/*', + '**/storybook-static/*', + '**/.storybook/*', + '**/public/*', + ], + }, + + // 2. TypeScript configuration + { + files: ['**/*.{js,ts,tsx}'], + languageOptions: { + globals: { + ...globals.browser, + }, + parser: tsParser, + parserOptions: { + ecmaFeatures: { jsx: true }, + ecmaVersion: 2020, + }, + }, + }, + + // 3. Recommended configs + eslint.configs.recommended, + js.configs.recommended, + jsxA11y.flatConfigs.recommended, + perfectionist.configs['recommended-natural'], + pluginCypress.configs.recommended, + react.configs.flat.recommended, + reactHooks.configs['recommended-latest'], + sonarjs.configs.recommended, + tseslint.configs.recommended, + + // 4. Base rules + { + files: ['**/*.{js,ts,tsx}'], + rules: { + 'array-callback-return': 'error', + 'comma-dangle': 'off', + curly: 'warn', + eqeqeq: 'warn', + 'no-await-in-loop': 'error', + 'no-bitwise': 'error', + 'no-caller': 'error', + 'no-case-declarations': 'warn', + 'no-console': 'error', + 'no-empty': 'warn', + 'no-eval': 'error', + 'no-extra-boolean-cast': 'warn', + 'no-invalid-this': 'off', + 'no-loop-func': 'error', + 'no-mixed-requires': 'warn', + 'no-multiple-empty-lines': 'error', + 'no-new-wrappers': 'error', + 'no-restricted-imports': [ + 'error', + 'rxjs', + '@mui/core', + '@mui/system', + '@mui/icons-material', + { + importNames: ['Typography'], + message: + 'Please use Typography component from @linode/ui instead of @mui/material', + name: '@mui/material', + }, + { + importNames: ['Link'], + message: + 'Please use the Link component from src/components/Link instead of react-router-dom', + name: 'react-router-dom', + }, + ], + 'no-restricted-syntax': [ + 'error', + { + message: + "The 'data-test-id' attribute is not allowed; use 'data-testid' instead.", + selector: "JSXAttribute[name.name='data-test-id']", + }, + ], + 'no-throw-literal': 'warn', + 'no-trailing-spaces': 'warn', + 'no-undef-init': 'off', + 'no-unused-expressions': 'warn', + 'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + 'no-useless-escape': 'warn', + 'object-shorthand': 'warn', + 'sort-keys': 'off', + 'spaced-comment': 'warn', + }, + }, + + // 5. React and React Hooks + { + files: ['**/*.{ts,tsx}'], + plugins: { + react, + }, + rules: { + 'react-hooks/exhaustive-deps': 'warn', + 'react-hooks/rules-of-hooks': 'error', + 'react/display-name': 'off', + 'react/jsx-no-bind': 'off', + 'react/jsx-no-script-url': 'error', + 'react/jsx-no-useless-fragment': 'warn', + 'react/no-unescaped-entities': 'warn', + 'react/prop-types': 'off', + 'react/self-closing-comp': 'warn', + }, + }, + + // 6. TypeScript-specific + { + files: ['**/*.{ts,tsx}'], + rules: { + '@typescript-eslint/consistent-type-imports': 'warn', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/interface-name-prefix': 'off', + '@typescript-eslint/naming-convention': [ + 'warn', + { + format: ['camelCase', 'UPPER_CASE', 'PascalCase'], + leadingUnderscore: 'allow', + selector: 'variable', + trailingUnderscore: 'allow', + }, + { + format: null, + modifiers: ['destructured'], + selector: 'variable', + }, + { + format: ['camelCase', 'PascalCase'], + selector: 'function', + }, + { + format: ['camelCase'], + leadingUnderscore: 'allow', + selector: 'parameter', + }, + { + format: ['PascalCase'], + selector: 'typeLike', + }, + ], + '@typescript-eslint/no-empty-interface': 'warn', + '@typescript-eslint/no-empty-object-type': 'warn', + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-inferrable-types': 'off', + '@typescript-eslint/no-namespace': 'warn', + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/no-use-before-define': 'off', + }, + }, + + // 7. XSS + { + files: ['**/*.{js,ts,tsx}'], + plugins: { + xss, + }, + }, + + // 8. SonarJS + { + files: ['**/*.{js,ts,tsx}'], + rules: { + 'sonarjs/arrow-function-convention': 'off', + 'sonarjs/cognitive-complexity': 'off', + 'sonarjs/duplicates-in-character-class': 'warn', + 'sonarjs/no-clear-text-protocols': 'off', + 'sonarjs/no-commented-code': 'warn', + 'sonarjs/no-duplicate-string': 'warn', + 'sonarjs/no-identical-functions': 'warn', + 'sonarjs/no-ignored-exceptions': 'warn', + 'sonarjs/no-nested-conditional': 'off', + 'sonarjs/no-nested-functions': 'warn', + 'sonarjs/no-redundant-jump': 'warn', + 'sonarjs/no-small-switch': 'warn', + 'sonarjs/prefer-immediate-return': 'warn', + 'sonarjs/prefer-single-boolean-return': 'off', + 'sonarjs/redundant-type-aliases': 'warn', + 'sonarjs/todo-tag': 'warn', + 'sonarjs/single-character-alternation': 'warn', + 'sonarjs/no-duplicate-in-composite': 'warn', + 'sonarjs/no-nested-template-literals': 'off', + 'sonarjs/public-static-readonly': 'warn', + 'sonarjs/concise-regex': 'warn', + 'sonarjs/use-type-alias': 'warn', + }, + }, + + // 9. JSX A11y + { + files: ['**/*.{jsx,tsx}'], + rules: { + 'jsx-a11y/aria-role': [ + 'error', + { + allowedInvalidRoles: [ + 'graphics-document', + 'graphics-object', + 'graphics-symbol', + ], + }, + ], + }, + }, + + // 10. Perfectionist + { + files: ['**/*.{js,ts,tsx}'], + rules: { + 'perfectionist/sort-array-includes': 'warn', + 'perfectionist/sort-classes': 'warn', + 'perfectionist/sort-enums': 'warn', + 'perfectionist/sort-exports': 'warn', + 'perfectionist/sort-heritage-clauses': 'off', + 'perfectionist/sort-imports': [ + 'warn', + { + customGroups: { + type: { + react: ['^react$', '^react-.+'], + src: ['^src$'], + }, + value: { + react: ['^react$', '^react-.+'], + src: ['^src$', '^src/.+'], + }, + }, + groups: [ + ['react', 'builtin', 'external'], + ['src', 'internal'], + ['parent', 'sibling', 'index'], + 'object', + 'unknown', + [ + 'type', + 'internal-type', + 'parent-type', + 'sibling-type', + 'index-type', + ], + ], + newlinesBetween: 'always', + }, + ], + 'perfectionist/sort-interfaces': 'warn', + 'perfectionist/sort-intersection-types': 'off', + 'perfectionist/sort-jsx-props': 'warn', + 'perfectionist/sort-modules': 'off', + 'perfectionist/sort-named-exports': 'warn', + 'perfectionist/sort-named-imports': 'warn', + 'perfectionist/sort-object-types': 'warn', + 'perfectionist/sort-objects': 'off', + 'perfectionist/sort-sets': 'off', + 'perfectionist/sort-switch-case': 'warn', + 'perfectionist/sort-union-types': 'warn', + }, + }, + + // 11. Cloud Manager + { + files: ['**/*.{js,ts,tsx}'], + plugins: { + '@linode/cloud-manager': linodeRules, + }, + rules: { + '@linode/cloud-manager/no-custom-fontWeight': 'warn', + '@linode/cloud-manager/deprecate-formik': 'warn', + '@linode/cloud-manager/no-createLinode': 'off', + '@linode/cloud-manager/no-mui-theme-spacing': 'warn', + }, + }, + + // 12. Unit tests, factories, mocks & stories + { + files: [ + '**/*.test.{js,ts,tsx}', + '**/*.stories.{js,ts,tsx}', + '**/factories/**/*.{js,ts,tsx}', + '**/__data__/**/*.{js,ts,tsx}', + '**/mocks/**/*.{js,ts,tsx}', + ], + plugins: { + 'testing-library': testingLibrary, + }, + rules: { + '@typescript-eslint/no-empty-object-type': 'off', + 'no-useless-escape': 'off', + 'no-empty-pattern': 'off', + ...Object.fromEntries( + Object.keys(testingLibrary.rules).map((rule) => { + // Special case for consistent-data-testid which needs config + if (rule === 'consistent-data-testid') { + return [ + `testing-library/${rule}`, + [ + 'warn', + { + testIdAttribute: 'data-testid', + testIdPattern: '^[a-z-]+$', + }, + ], + ]; + } + // All other rules just get set to warn + return [`testing-library/${rule}`, 'warn']; + }) + ), + // This will make all sonar rules warnings. + // It is a good idea to keep them as such so that we don't introduce new issues that could trigger dependabot or security scripts. + ...Object.fromEntries( + Object.keys(sonarjs.rules).map((rule) => [`sonarjs/${rule}`, 'warn']) + ), + 'sonarjs/arrow-function-convention': 'off', + 'sonarjs/enforce-trailing-comma': 'off', + 'sonarjs/file-header': 'off', + 'sonarjs/no-implicit-dependencies': 'off', + 'sonarjs/no-reference-error': 'off', + 'sonarjs/no-wildcard-import': 'off', + 'sonarjs/no-hardcoded-ip': 'off', + 'sonarjs/pseudo-random': 'off', + }, + }, + + // 13. Cypress + { + files: ['**/cypress/**/*.{js,ts,tsx}'], + rules: { + 'no-console': 'off', + 'no-unused-expressions': 'off', + 'sonarjs/pseudo-random': 'off', + 'sonarjs/no-hardcoded-ip': 'off', + '@linode/cloud-manager/no-createLinode': 'error', + '@typescript-eslint/no-unused-expressions': 'off', + }, + }, + + // 14. Tanstack Router (temporary) + { + files: [ + // for each new features added to the migration router, add its directory here + 'src/features/Betas/**/*', + 'src/features/Domains/**/*', + 'src/features/Firewalls/**/*', + 'src/features/Images/**/*', + 'src/features/Longview/**/*', + 'src/features/NodeBalancers/**/*', + 'src/features/ObjectStorage/**/*', + 'src/features/PlacementGroups/**/*', + 'src/features/StackScripts/**/*', + 'src/features/Volumes/**/*', + 'src/features/VPCs/**/*', + ], + rules: { + 'no-restricted-imports': [ + // This needs to remain an error however trying to link to a feature that is not yet migrated will break the router + // For those cases react-router-dom history.push is still needed + // using `eslint-disable-next-line no-restricted-imports` can help bypass those imports + 'error', + { + paths: [ + { + importNames: [ + // intentionally not including in this list as this will be updated last globally + 'useNavigate', + 'useParams', + 'useLocation', + 'useHistory', + 'useRouteMatch', + 'matchPath', + 'MemoryRouter', + 'Route', + 'RouteProps', + 'Switch', + 'Redirect', + 'RouteComponentProps', + 'withRouter', + ], + message: + 'Please use routing utilities intended for @tanstack/react-router.', + name: 'react-router-dom', + }, + { + importNames: ['TabLinkList'], + message: + 'Please use the TanStackTabLinkList component for components being migrated to TanStack Router.', + name: 'src/components/Tabs/TabLinkList', + }, + { + importNames: ['OrderBy', 'default'], + message: + 'Please use useOrderV2 hook for components being migrated to TanStack Router.', + name: 'src/components/OrderBy', + }, + { + importNames: ['Prompt'], + message: + 'Please use the TanStack useBlocker hook for components/features being migrated to TanStack Router.', + name: 'src/components/Prompt/Prompt', + }, + ], + }, + ], + }, + }, + + // 15. Prettier (coming last as recommended) + { + files: ['**/*.{js,ts,tsx}'], + plugins: { + prettier, + }, + rules: { + ...eslintConfigPrettier.rules, + 'prettier/prettier': 'warn', + }, + }, +]; + +export default defineConfig(baseConfig); diff --git a/packages/manager/package.json b/packages/manager/package.json index 0e61a70e548..05e9a9d1d02 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -2,7 +2,7 @@ "name": "linode-manager", "author": "Linode", "description": "The Linode Manager website", - "version": "1.139.1", + "version": "1.140.0", "private": true, "type": "module", "bugs": { @@ -45,6 +45,7 @@ "@tanstack/react-query-devtools": "5.51.24", "@tanstack/react-router": "^1.111.11", "@xterm/xterm": "^5.5.0", + "akamai-cds-react-components": "0.0.1-alpha.6", "algoliasearch": "^4.14.3", "axios": "~1.8.3", "braintree-web": "^3.92.2", @@ -126,7 +127,6 @@ }, "devDependencies": { "@4tw/cypress-drag-drop": "^2.3.0", - "@linode/eslint-plugin-cloud-manager": "^0.0.10", "@storybook/addon-a11y": "^8.6.7", "@storybook/addon-actions": "^8.6.7", "@storybook/addon-controls": "^8.6.7", @@ -151,6 +151,7 @@ "@types/chai-string": "^1.4.5", "@types/chart.js": "^2.9.21", "@types/css-mediaquery": "^0.1.1", + "@types/eslint-plugin-jsx-a11y": "^6.10.0", "@types/he": "^1.1.0", "@types/history": "4", "@types/jspdf": "^1.3.3", @@ -172,15 +173,13 @@ "@types/throttle-debounce": "^1.0.0", "@types/uuid": "^3.4.3", "@types/zxcvbn": "^4.4.0", - "@typescript-eslint/eslint-plugin": "^6.21.0", - "@typescript-eslint/parser": "^6.21.0", "@vitejs/plugin-react-swc": "^3.7.2", "@vitest/coverage-v8": "^3.0.7", "axe-core": "^4.10.2", "chai-string": "^1.5.0", "concurrently": "^9.1.0", "css-mediaquery": "^0.1.2", - "cypress": "14.0.1", + "cypress": "14.3.0", "cypress-axe": "^1.6.0", "cypress-file-upload": "^5.0.8", "cypress-mochawesome-reporter": "^3.8.2", @@ -189,32 +188,18 @@ "cypress-real-events": "^1.14.0", "cypress-vite": "^1.6.0", "dotenv": "^16.0.3", - "eslint": "^7.1.0", - "eslint-config-prettier": "~8.1.0", - "eslint-plugin-cypress": "^2.11.3", - "eslint-plugin-jsx-a11y": "^6.7.1", - "eslint-plugin-perfectionist": "^1.4.0", - "eslint-plugin-prettier": "~3.3.1", - "eslint-plugin-ramda": "^2.5.1", - "eslint-plugin-react": "^7.19.0", - "eslint-plugin-react-hooks": "^3.0.0", - "eslint-plugin-react-refresh": "0.4.13", - "eslint-plugin-scanjs-rules": "^0.2.1", - "eslint-plugin-sonarjs": "^0.5.0", - "eslint-plugin-testing-library": "^3.1.2", - "eslint-plugin-xss": "^0.1.10", "factory.ts": "^0.5.1", "glob": "^10.3.1", + "globals": "^16.0.0", "history": "4", "jsdom": "^24.1.1", "mocha-junit-reporter": "^2.2.1", "msw": "^2.2.3", "pdfreader": "^3.0.7", - "prettier": "~2.2.1", "redux-mock-store": "^1.5.3", "storybook": "^8.6.7", "storybook-dark-mode": "4.0.1", - "vite": "^6.2.4", + "vite": "^6.2.5", "vite-plugin-svgr": "^3.2.0" }, "browserslist": [ @@ -223,4 +208,4 @@ "Firefox ESR", "not dead" ] -} +} \ No newline at end of file diff --git a/packages/manager/public/assets/seatable.svg b/packages/manager/public/assets/seatable.svg deleted file mode 100644 index 9001f54dcfe..00000000000 --- a/packages/manager/public/assets/seatable.svg +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - diff --git a/packages/manager/public/assets/utunnel.svg b/packages/manager/public/assets/utunnel.svg deleted file mode 100755 index 58fbf7e8ab2..00000000000 --- a/packages/manager/public/assets/utunnel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/packages/manager/public/assets/victoriametricssingle.svg b/packages/manager/public/assets/victoriametricssingle.svg deleted file mode 100644 index d5dd3b6dee8..00000000000 --- a/packages/manager/public/assets/victoriametricssingle.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/packages/manager/public/assets/warpspeed.svg b/packages/manager/public/assets/warpspeed.svg deleted file mode 100755 index 081569f5ce4..00000000000 --- a/packages/manager/public/assets/warpspeed.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/packages/manager/public/assets/white/seatable.svg b/packages/manager/public/assets/white/seatable.svg deleted file mode 100644 index 8dd3b420bf0..00000000000 --- a/packages/manager/public/assets/white/seatable.svg +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/packages/manager/public/assets/white/utunnel.svg b/packages/manager/public/assets/white/utunnel.svg deleted file mode 100644 index e18aabc5076..00000000000 --- a/packages/manager/public/assets/white/utunnel.svg +++ /dev/null @@ -1,28 +0,0 @@ - - - - - diff --git a/packages/manager/public/assets/white/victoriametricssingle.svg b/packages/manager/public/assets/white/victoriametricssingle.svg deleted file mode 100644 index 52c27383965..00000000000 --- a/packages/manager/public/assets/white/victoriametricssingle.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/packages/manager/public/assets/white/warpspeed.svg b/packages/manager/public/assets/white/warpspeed.svg deleted file mode 100644 index 1ac8d50a9aa..00000000000 --- a/packages/manager/public/assets/white/warpspeed.svg +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/packages/manager/src/MainContent.tsx b/packages/manager/src/MainContent.tsx index 02c79b145d4..441bdcaf82d 100644 --- a/packages/manager/src/MainContent.tsx +++ b/packages/manager/src/MainContent.tsx @@ -1,3 +1,9 @@ +import { + useAccountSettings, + useMutatePreferences, + usePreferences, + useProfile, +} from '@linode/queries'; import { Box } from '@linode/ui'; import { useMediaQuery } from '@mui/material'; import Grid from '@mui/material/Grid2'; @@ -24,12 +30,6 @@ import { useNotificationContext, } from 'src/features/NotificationCenter/NotificationCenterContext'; import { TopMenu } from 'src/features/TopMenu/TopMenu'; -import { - useMutatePreferences, - usePreferences, - useAccountSettings, - useProfile, -} from '@linode/queries'; import { useIsPageScrollable } from './components/PrimaryNav/utils'; import { ENABLE_MAINTENANCE_MODE } from './constants'; @@ -38,7 +38,7 @@ import { sessionExpirationContext } from './context/sessionExpirationContext'; import { switchAccountSessionContext } from './context/switchAccountSessionContext'; import { useIsACLPEnabled } from './features/CloudPulse/Utils/utils'; import { useIsDatabasesEnabled } from './features/Databases/utilities'; -import { useIsIAMEnabled } from './features/IAM/Shared/utilities'; +import { useIsIAMEnabled } from './features/IAM/hooks/useIsIAMEnabled'; import { TOPMENU_HEIGHT } from './features/TopMenu/constants'; import { useGlobalErrors } from './hooks/useGlobalErrors'; import { migrationRouter } from './routes'; @@ -123,7 +123,6 @@ const Kubernetes = React.lazy(() => default: module.Kubernetes, })) ); -const ObjectStorage = React.lazy(() => import('src/features/ObjectStorage')); const Profile = React.lazy(() => import('src/features/Profile/Profile').then((module) => ({ default: module.Profile, @@ -157,7 +156,6 @@ const AccountActivationLanding = React.lazy( () => import('src/components/AccountActivation/AccountActivationLanding') ); const Databases = React.lazy(() => import('src/features/Databases')); -const VPC = React.lazy(() => import('src/features/VPCs')); const CloudPulseMetrics = React.lazy(() => import('src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding').then( @@ -299,8 +297,8 @@ export const MainContent = () => { isNarrowViewport ? '100%' : isPageScrollable - ? '100vh' - : `calc(100vh - ${TOPMENU_HEIGHT}px)` + ? '100vh' + : `calc(100vh - ${TOPMENU_HEIGHT}px)` } position="sticky" top={0} @@ -321,9 +319,9 @@ export const MainContent = () => { marginLeft: isNarrowViewport ? 0 : desktopMenuIsOpen || - (desktopMenuIsOpen && desktopMenuIsOpen === true) - ? SIDEBAR_COLLAPSED_WIDTH - : SIDEBAR_WIDTH, + (desktopMenuIsOpen && desktopMenuIsOpen === true) + ? SIDEBAR_COLLAPSED_WIDTH + : SIDEBAR_WIDTH, }} > @@ -366,10 +364,6 @@ export const MainContent = () => { - {isIAMEnabled && ( @@ -382,7 +376,6 @@ export const MainContent = () => { {isDatabasesEnabled && ( )} - {isACLPEnabled && ( - - diff --git a/packages/manager/src/components/AbuseTicketBanner/AbuseTicketBanner.tsx b/packages/manager/src/components/AbuseTicketBanner/AbuseTicketBanner.tsx index 40e1067eb5f..22dfac315c3 100644 --- a/packages/manager/src/components/AbuseTicketBanner/AbuseTicketBanner.tsx +++ b/packages/manager/src/components/AbuseTicketBanner/AbuseTicketBanner.tsx @@ -33,7 +33,7 @@ export const AbuseTicketBanner = () => { const href = multiple ? '/support/tickets' - : abuseTickets[0].entity?.url ?? ''; + : (abuseTickets[0].entity?.url ?? ''); const isViewingTicket = location.pathname.match(href); return ( @@ -43,7 +43,6 @@ export const AbuseTicketBanner = () => { expiry: DateTime.utc().plus({ days: 7 }).toISO(), label: preferenceKey, }} - important preferenceKey={preferenceKey} variant="warning" > diff --git a/packages/manager/src/components/AccessPanel/UserSSHKeyPanel.test.tsx b/packages/manager/src/components/AccessPanel/UserSSHKeyPanel.test.tsx index d271b141c43..6cc350e05aa 100644 --- a/packages/manager/src/components/AccessPanel/UserSSHKeyPanel.test.tsx +++ b/packages/manager/src/components/AccessPanel/UserSSHKeyPanel.test.tsx @@ -1,8 +1,8 @@ +import { profileFactory, sshKeyFactory } from '@linode/utilities'; import { waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; -import { profileFactory, sshKeyFactory } from 'src/factories'; import { accountUserFactory } from 'src/factories/accountUsers'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; diff --git a/packages/manager/src/components/AreaChart/AreaChart.test.tsx b/packages/manager/src/components/AreaChart/AreaChart.test.tsx index fe8b4c28ab1..99184b881b0 100644 --- a/packages/manager/src/components/AreaChart/AreaChart.test.tsx +++ b/packages/manager/src/components/AreaChart/AreaChart.test.tsx @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/no-empty-function */ +import { waitFor } from '@testing-library/react'; import * as React from 'react'; import { renderWithTheme } from 'src/utilities/testHelpers'; @@ -32,10 +32,13 @@ class ResizeObserver { describe('AreaChart', () => { window.ResizeObserver = ResizeObserver; - it('renders an AreaChart', () => { + it('renders an AreaChart', async () => { const { container } = renderWithTheme(); - expect(container.querySelector('recharts-responsive-container')) - .toBeVisible; + await waitFor(() => { + expect( + container.querySelector('[class*="recharts-responsive-container"]') + ).toBeVisible(); + }); }); }); diff --git a/packages/manager/src/components/AreaChart/AreaChart.tsx b/packages/manager/src/components/AreaChart/AreaChart.tsx index 7eea892a55e..dd1ba65a1db 100644 --- a/packages/manager/src/components/AreaChart/AreaChart.tsx +++ b/packages/manager/src/components/AreaChart/AreaChart.tsx @@ -226,7 +226,7 @@ export const AreaChart = (props: AreaChartProps) => { {item.dataKey} - + {tooltipValueFormatter(item.value, unit)} @@ -267,7 +267,11 @@ export const AreaChart = (props: AreaChartProps) => { return ( <> - + <_AreaChart aria-label={ariaLabel} data={data} margin={margin}> { vertical={false} /> { scale="time" stroke={theme.color.label} tickFormatter={xAxisTickFormatter} + ticks={ + xAxisTickCount + ? generate12HourTicks(data, timezone, xAxisTickCount) + : [] + } type="number" /> } contentStyle={{ color: theme.tokens.color.Neutrals[70], }} @@ -303,7 +308,6 @@ export const AreaChart = (props: AreaChartProps) => { color: theme.tokens.color.Neutrals[70], font: theme.font.bold, }} - content={} /> {showLegend && !legendRows && ( { {value} )} + iconType="square" onClick={({ dataKey }) => { if (dataKey) { handleLegendClick(dataKey as string); } }} - iconType="square" wrapperStyle={legendStyles} /> )} @@ -333,7 +337,7 @@ export const AreaChart = (props: AreaChartProps) => { dataKey={dataKey} dot={{ r: showDot ? dotRadius : 0 }} fill={color} - fillOpacity={variant === 'line' ? 0 : fillOpacity ?? 1} + fillOpacity={variant === 'line' ? 0 : (fillOpacity ?? 1)} hide={activeSeries.includes(dataKey)} isAnimationActive={false} key={dataKey} diff --git a/packages/manager/src/components/Avatar/Avatar.test.tsx b/packages/manager/src/components/Avatar/Avatar.test.tsx index bbd256aa30e..2ef7c70fd7d 100644 --- a/packages/manager/src/components/Avatar/Avatar.test.tsx +++ b/packages/manager/src/components/Avatar/Avatar.test.tsx @@ -1,6 +1,6 @@ +import { profileFactory } from '@linode/utilities'; import * as React from 'react'; -import { profileFactory } from 'src/factories/profile'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { Avatar } from './Avatar'; diff --git a/packages/manager/src/components/ConfirmationDialog/ConfirmationDialog.stories.tsx b/packages/manager/src/components/ConfirmationDialog/ConfirmationDialog.stories.tsx index 302a93a8d1c..caa5772f732 100644 --- a/packages/manager/src/components/ConfirmationDialog/ConfirmationDialog.stories.tsx +++ b/packages/manager/src/components/ConfirmationDialog/ConfirmationDialog.stories.tsx @@ -63,7 +63,7 @@ export const Default: Story = { ), }; -export const Error: Story = { +export const ConfirmationDialogError: Story = { args: { error: 'There was an error somewhere in the process.', }, diff --git a/packages/manager/src/components/CopyTooltip/CopyTooltip.tsx b/packages/manager/src/components/CopyTooltip/CopyTooltip.tsx index 04a31c65424..6d57f5f21ca 100644 --- a/packages/manager/src/components/CopyTooltip/CopyTooltip.tsx +++ b/packages/manager/src/components/CopyTooltip/CopyTooltip.tsx @@ -1,4 +1,4 @@ -import { Tooltip, VisibilityTooltip, omittedProps } from '@linode/ui'; +import { omittedProps, Tooltip, VisibilityTooltip } from '@linode/ui'; import { styled } from '@mui/material/styles'; import copy from 'copy-to-clipboard'; import * as React from 'react'; @@ -25,18 +25,23 @@ export interface CopyTooltipProps { */ disabled?: boolean; /** - * If true, the text will be masked with dots when displayed. It will still be copyable. + * If true, the component is in controlled mode for text masking, meaning the parent component handles the visibility toggle. * @default false */ - masked?: boolean; + isMaskingControlled?: boolean; /** - * Optionally specifies the length of the masked text to depending on data type (e.g. 'ipv4', 'ipv6', 'plaintext'); if not provided, will use a default length. + * If true, the text will be masked with dots when displayed. It will still be copyable. + * @default false */ - maskedTextLength?: MaskableTextLength; + masked?: boolean; /** * Callback to be executed when the icon is clicked. */ + /** + * Optionally specifies the length of the masked text to depending on data type (e.g. 'ipv4', 'ipv6', 'plaintext'); if not provided, will use a default length. + */ + maskedTextLength?: MaskableTextLength | number; onClickCallback?: () => void; /** * The placement of the tooltip. @@ -59,6 +64,7 @@ export const CopyTooltip = (props: CopyTooltipProps) => { className, copyableText, disabled, + isMaskingControlled, masked, maskedTextLength, onClickCallback, @@ -67,7 +73,11 @@ export const CopyTooltip = (props: CopyTooltipProps) => { } = props; const [copied, setCopied] = React.useState(false); - const [isTextMasked, setIsTextMasked] = React.useState(masked); + const [isTextMaskedInternally, setIsTextMaskedInternally] = + React.useState(masked); + + // Use the parent component's state for text masking if in controlled mode; otherwise use the internal state. + const isTextMasked = isMaskingControlled ? masked : isTextMaskedInternally; const displayText = isTextMasked ? createMaskedText(text, maskedTextLength) @@ -111,9 +121,9 @@ export const CopyTooltip = (props: CopyTooltipProps) => { > {CopyButton} - {masked && ( + {masked && !isMaskingControlled && ( setIsTextMasked(!isTextMasked)} + handleClick={() => setIsTextMaskedInternally(!isTextMaskedInternally)} isVisible={!isTextMasked} /> )} @@ -129,6 +139,7 @@ export const StyledIconButton = styled('button', { 'onClickCallback', 'masked', 'maskedTextLength', + 'isMaskingControlled', ]), })>(({ theme, ...props }) => ({ '& svg': { diff --git a/packages/manager/src/components/DateTimeDisplay/DateTimeDisplay.test.tsx b/packages/manager/src/components/DateTimeDisplay/DateTimeDisplay.test.tsx index 86c393f0c56..25b977b5876 100644 --- a/packages/manager/src/components/DateTimeDisplay/DateTimeDisplay.test.tsx +++ b/packages/manager/src/components/DateTimeDisplay/DateTimeDisplay.test.tsx @@ -4,8 +4,17 @@ import * as React from 'react'; import { ISO_DATETIME_NO_TZ_FORMAT } from 'src/constants'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import { DateTimeDisplay, DateTimeDisplayProps } from './DateTimeDisplay'; -vi.mock('../../utilities/getUserTimezone'); +import { DateTimeDisplay } from './DateTimeDisplay'; + +import type { DateTimeDisplayProps } from './DateTimeDisplay'; + +vi.mock('@linode/utilities', async () => { + const actual = await vi.importActual('@linode/utilities'); + return { + ...actual, + getUserTimezone: vi.fn().mockReturnValue('utc'), + }; +}); const APIDate = '2018-07-20T04:23:17'; diff --git a/packages/manager/src/components/DateTimeDisplay/DateTimeDisplay.tsx b/packages/manager/src/components/DateTimeDisplay/DateTimeDisplay.tsx index 467a8ec9053..c2fdfaeee14 100644 --- a/packages/manager/src/components/DateTimeDisplay/DateTimeDisplay.tsx +++ b/packages/manager/src/components/DateTimeDisplay/DateTimeDisplay.tsx @@ -1,7 +1,7 @@ +import { useProfile } from '@linode/queries'; import { Typography } from '@linode/ui'; import * as React from 'react'; -import { useProfile } from '@linode/queries'; import { formatDate } from 'src/utilities/formatDate'; import type { TimeInterval } from 'src/utilities/formatDate'; diff --git a/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx b/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx index 2bf259bcc1f..8269edc4a7f 100644 --- a/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx +++ b/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx @@ -1,5 +1,6 @@ import { CircleProgress, + CloseIcon, IconButton, InputAdornment, TextField, @@ -7,7 +8,6 @@ import { import * as React from 'react'; import { debounce } from 'throttle-debounce'; -import Close from 'src/assets/icons/close.svg'; import Search from 'src/assets/icons/search.svg'; import type { InputProps, TextFieldProps } from '@linode/ui'; @@ -111,7 +111,7 @@ export const DebouncedSearchTextField = React.memo( aria-label="Clear" size="small" > - + )} diff --git a/packages/manager/src/components/DeletionDialog/DeletionDialog.stories.tsx b/packages/manager/src/components/DeletionDialog/DeletionDialog.stories.tsx index 278748ecc43..016ea3debf3 100644 --- a/packages/manager/src/components/DeletionDialog/DeletionDialog.stories.tsx +++ b/packages/manager/src/components/DeletionDialog/DeletionDialog.stories.tsx @@ -62,14 +62,14 @@ export const Default: Story = { render: (args) => {args.children}, }; -export const Error: Story = { +export const DeletionDialogError: Story = { args: { error: 'There was an error deleting this Linode.', }, render: (args) => {args.children}, }; -export const Loading: Story = { +export const DeletionDialogLoading: Story = { args: { loading: true, }, diff --git a/packages/manager/src/components/DismissibleBanner/DismissibleBanner.stories.tsx b/packages/manager/src/components/DismissibleBanner/DismissibleBanner.stories.tsx index a6dafde7fd6..77a17ecc46c 100644 --- a/packages/manager/src/components/DismissibleBanner/DismissibleBanner.stories.tsx +++ b/packages/manager/src/components/DismissibleBanner/DismissibleBanner.stories.tsx @@ -11,7 +11,7 @@ type Story = StoryObj; export const Default: Story = { render: (args) => ( - + This is an example of a dismissible banner. ), @@ -28,6 +28,7 @@ export const CallToActionBanner: Story = { Upgrade Version } + forceImportantIconVerticalCenter preferenceKey="cluster-v1" variant="info" > @@ -55,6 +56,20 @@ export const BetaBanner: Story = { ), }; +export const InfoWithLongTextAndMarkup: StoryObj = { + render: () => ( + + + This is a dismissible informational notice with a title. + + This notice contains long text that should wrap. + + ), +}; + const meta: Meta = { args: { preferenceKey: 'dismissible-banner' }, component: DismissibleBanner, diff --git a/packages/manager/src/components/DismissibleBanner/DismissibleBanner.tsx b/packages/manager/src/components/DismissibleBanner/DismissibleBanner.tsx index 14c5ed1017e..fc4de218335 100644 --- a/packages/manager/src/components/DismissibleBanner/DismissibleBanner.tsx +++ b/packages/manager/src/components/DismissibleBanner/DismissibleBanner.tsx @@ -1,5 +1,4 @@ -import { IconButton, Notice, Stack } from '@linode/ui'; -import Close from '@mui/icons-material/Close'; +import { CloseIcon, IconButton, Notice, Stack } from '@linode/ui'; import * as React from 'react'; import { useDismissibleNotifications } from 'src/hooks/useDismissibleNotifications'; @@ -12,6 +11,10 @@ interface Props extends NoticeProps { * Optional element to pass to the banner to trigger actions */ actionButton?: JSX.Element; + /** + * If true, the important icon will be vertically centered with the text no matter the height of the text. + */ + forceImportantIconVerticalCenter?: boolean; /** * Additional controls to pass to the Dismissible Banner */ @@ -55,22 +58,32 @@ export const DismissibleBanner = (props: Props) => { aria-label={`Dismiss ${preferenceKey} banner`} data-testid="notice-dismiss" onClick={handleDismiss} - sx={{ padding: 1 }} + sx={(theme) => ({ + padding: theme.spacingFunction(2), + '& svg': { + width: 16, + height: 16, + '& path': { + fill: theme.tokens.component.NotificationBanner.Icon, + }, + }, + })} > - + ); return ( - theme.palette.background.paper} - display="flex" - gap={1} - justifyContent="space-between" - {...rest} - > - {children} - + theme.palette.background.paper} {...rest}> + + {children} + + {actionButton} {dismissibleButton} @@ -84,10 +97,8 @@ export const useDismissibleBanner = ( preferenceKey: string, options?: DismissibleNotificationOptions ) => { - const { - dismissNotifications, - hasDismissedNotifications, - } = useDismissibleNotifications(); + const { dismissNotifications, hasDismissedNotifications } = + useDismissibleNotifications(); const hasDismissedBanner = hasDismissedNotifications([preferenceKey]); diff --git a/packages/manager/src/components/EmptyLandingPageResources/ResourcesLinksTypes.ts b/packages/manager/src/components/EmptyLandingPageResources/ResourcesLinksTypes.ts index c94d2a6f6a1..2129b44b6e7 100644 --- a/packages/manager/src/components/EmptyLandingPageResources/ResourcesLinksTypes.ts +++ b/packages/manager/src/components/EmptyLandingPageResources/ResourcesLinksTypes.ts @@ -4,7 +4,7 @@ interface ResourcesLink { to: string; } -export interface linkAnalyticsEvent { +export interface LinkAnalyticsEvent { action: string; category: string; } @@ -17,7 +17,7 @@ export interface ResourcesHeaders { } export interface ResourcesLinks { - linkAnalyticsEvent: linkAnalyticsEvent; + linkAnalyticsEvent: LinkAnalyticsEvent; links: ResourcesLink[]; onClick?: () => void; } diff --git a/packages/manager/src/components/EmptyLandingPageResources/ResourcesSection.tsx b/packages/manager/src/components/EmptyLandingPageResources/ResourcesSection.tsx index 4c2107f9d54..f7d72ddad85 100644 --- a/packages/manager/src/components/EmptyLandingPageResources/ResourcesSection.tsx +++ b/packages/manager/src/components/EmptyLandingPageResources/ResourcesSection.tsx @@ -19,7 +19,7 @@ import { import type { ResourcesHeaders, ResourcesLinkSection, - linkAnalyticsEvent, + LinkAnalyticsEvent, } from 'src/components/EmptyLandingPageResources/ResourcesLinksTypes'; interface ButtonProps { @@ -62,7 +62,7 @@ interface ResourcesSectionProps { /** * The event data to be sent when the call to action is clicked */ - linkAnalyticsEvent: linkAnalyticsEvent; + linkAnalyticsEvent: LinkAnalyticsEvent; /** * If true, the transfer display will be shown at the bottom * */ @@ -82,14 +82,14 @@ interface ResourcesSectionProps { const GuideLinks = ( guides: ResourcesLinkSection, - linkAnalyticsEvent: linkAnalyticsEvent + linkAnalyticsEvent: LinkAnalyticsEvent ) => ( ); const YoutubeLinks = ( youtube: ResourcesLinkSection, - linkAnalyticsEvent: linkAnalyticsEvent + linkAnalyticsEvent: LinkAnalyticsEvent ) => ( ; } export const migrationsDisabledRegex = /migrations are currently disabled/i; @@ -19,7 +23,7 @@ export const supportTextRegex = /(open a support ticket|contact Support)/i; export const allocationErrorRegex = /allocated more disk/i; export const ErrorMessage = (props: Props) => { - const { entity, formPayloadValues, message } = props; + const { entity, formPayloadValues, message, supportLinkProps } = props; if (migrationsDisabledRegex.test(message)) { return ; @@ -31,6 +35,7 @@ export const ErrorMessage = (props: Props) => { entity={entity} formPayloadValues={formPayloadValues} generalError={message} + supportLinkProps={supportLinkProps} /> ); } diff --git a/packages/manager/src/components/GaugePercent/GaugePercent.tsx b/packages/manager/src/components/GaugePercent/GaugePercent.tsx index 76e95cc0451..449e4845aa2 100644 --- a/packages/manager/src/components/GaugePercent/GaugePercent.tsx +++ b/packages/manager/src/components/GaugePercent/GaugePercent.tsx @@ -106,6 +106,7 @@ export const GaugePercent = React.memo((props: GaugePercentProps) => { // we use a reference to access it. // https://dev.to/vcanales/using-chart-js-in-a-function-component-with-react-hooks-246l if (graphRef.current) { + // eslint-disable-next-line sonarjs/constructor-for-side-effects new Chart(graphRef.current.getContext('2d'), { data: { datasets: graphDatasets, diff --git a/packages/manager/src/components/MaintenanceBanner/MaintenanceBanner.tsx b/packages/manager/src/components/MaintenanceBanner/MaintenanceBanner.tsx index 1463ab86584..cd0b94629b8 100644 --- a/packages/manager/src/components/MaintenanceBanner/MaintenanceBanner.tsx +++ b/packages/manager/src/components/MaintenanceBanner/MaintenanceBanner.tsx @@ -68,7 +68,7 @@ export const MaintenanceBanner = React.memo((props: Props) => { } return ( - + {generateIntroText(type, maintenanceStart, maintenanceEnd)} diff --git a/packages/manager/src/components/MaskableText/MaskableText.tsx b/packages/manager/src/components/MaskableText/MaskableText.tsx index 06c8edadcd6..b456706531d 100644 --- a/packages/manager/src/components/MaskableText/MaskableText.tsx +++ b/packages/manager/src/components/MaskableText/MaskableText.tsx @@ -1,7 +1,7 @@ -import { Stack, VisibilityTooltip, Typography } from '@linode/ui'; +import { usePreferences } from '@linode/queries'; +import { Stack, Typography, VisibilityTooltip } from '@linode/ui'; import * as React from 'react'; -import { usePreferences } from '@linode/queries'; import { createMaskedText } from 'src/utilities/createMaskedText'; import type { SxProps, Theme } from '@mui/material'; @@ -26,7 +26,7 @@ export interface MaskableTextProps { /** * Optionally specifies the length of the masked text to depending on data type (e.g. 'ipv4', 'ipv6', 'plaintext'); if not provided, will use a default length. */ - length?: MaskableTextLength; + length?: MaskableTextLength | number; /** * Optional styling for the masked and unmasked Typography */ diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index 42f08ccddc4..106d1bddc77 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -15,7 +15,7 @@ import { } from 'src/components/PrimaryNav/constants'; import { useIsACLPEnabled } from 'src/features/CloudPulse/Utils/utils'; import { useIsDatabasesEnabled } from 'src/features/Databases/utilities'; -import { useIsIAMEnabled } from 'src/features/IAM/Shared/utilities'; +import { useIsIAMEnabled } from 'src/features/IAM/hooks/useIsIAMEnabled'; import { useIsPlacementGroupsEnabled } from 'src/features/PlacementGroups/utils'; import { useFlags } from 'src/hooks/useFlags'; import { @@ -277,6 +277,8 @@ export const PrimaryNav = (props: PrimaryNavProps) => { isManaged, isPlacementGroupsEnabled, isACLPEnabled, + isIAMBeta, + isIAMEnabled, ] ); diff --git a/packages/manager/src/components/ProductInformationBanner/ProductInformationBanner.tsx b/packages/manager/src/components/ProductInformationBanner/ProductInformationBanner.tsx index 8269b2bd417..99ab5df9893 100644 --- a/packages/manager/src/components/ProductInformationBanner/ProductInformationBanner.tsx +++ b/packages/manager/src/components/ProductInformationBanner/ProductInformationBanner.tsx @@ -29,13 +29,6 @@ export const ProductInformationBanner = React.memo( return null; } - const isImportantBanner = - thisBanner.decoration.important === 'true' - ? true - : thisBanner.decoration.important === 'false' - ? false - : true; - let hasBannerExpired = true; try { hasBannerExpired = isAfter( @@ -52,7 +45,6 @@ export const ProductInformationBanner = React.memo( return ( - {text} - - ); + return {text}; }; diff --git a/packages/manager/src/components/Prompt/Prompt.tsx b/packages/manager/src/components/Prompt/Prompt.tsx index 1fe0dc739c0..05f1a631cdb 100644 --- a/packages/manager/src/components/Prompt/Prompt.tsx +++ b/packages/manager/src/components/Prompt/Prompt.tsx @@ -59,7 +59,6 @@ export const Prompt = React.memo((props: PromptProps) => { return; } - // eslint-disable-next-line scanjs-rules/call_addEventListener window.addEventListener('beforeunload', handleBeforeUnload); return () => window.removeEventListener('beforeunload', handleBeforeUnload); }, [props.when, props.confirmWhenLeaving]); diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.stories.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.stories.tsx index 3a66a2856ab..8b05ef83569 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.stories.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.stories.tsx @@ -11,7 +11,7 @@ import type { Meta, StoryObj } from '@storybook/react'; export const Default: StoryObj = { render: (args) => { const SelectWrapper = () => { - const [_, updateArgs] = useArgs(); + const [, updateArgs] = useArgs(); return ( - {selection.interfaceData?.ipv4?.vpc ?? null} + {selection.vpcIPv4 ?? null} - {determineNoneSingleOrMultipleWithChip( - selection.interfaceData?.ip_ranges ?? [] - )} + {determineNoneSingleOrMultipleWithChip(selection.vpcRanges ?? [])} {isRemovable && ( @@ -127,14 +122,16 @@ export const RemovableSelectionsListTable = ( }); return ( - <> - {headerText} + + + {headerText} + {tableHeadersJSX} {selectedOptionsJSX}
- +
); }; diff --git a/packages/manager/src/components/SelectionCard/CardBase.styles.ts b/packages/manager/src/components/SelectionCard/CardBase.styles.ts index 178da62a939..f202886dd45 100644 --- a/packages/manager/src/components/SelectionCard/CardBase.styles.ts +++ b/packages/manager/src/components/SelectionCard/CardBase.styles.ts @@ -49,19 +49,18 @@ export const CardBaseGrid = styled(Grid, { export const CardBaseIcon = styled(Grid, { label: 'CardBaseIcon', -})(({ theme }) => ({ +})({ '& img': { maxHeight: 32, maxWidth: 32, }, '& svg, & span': { - color: theme.tokens.color.Neutrals[50], fontSize: 32, }, alignItems: 'flex-end', display: 'flex', justifyContent: 'flex-end', -})); +}); export const CardBaseHeadings = styled(Grid, { label: 'CardBaseHeadings', diff --git a/packages/manager/src/components/ShowMoreExpansion/ShowMoreExpansion.tsx b/packages/manager/src/components/ShowMoreExpansion/ShowMoreExpansion.tsx index 5d1d983451e..c0165d58447 100644 --- a/packages/manager/src/components/ShowMoreExpansion/ShowMoreExpansion.tsx +++ b/packages/manager/src/components/ShowMoreExpansion/ShowMoreExpansion.tsx @@ -4,6 +4,7 @@ import Collapse from '@mui/material/Collapse'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; +import type { ButtonProps } from '@linode/ui'; import type { Theme } from '@mui/material/styles'; const useStyles = makeStyles()( @@ -39,6 +40,10 @@ const useStyles = makeStyles()( ); export interface ShowMoreExpansionProps { + /** + * Optional props that will be passed to the underlying button + */ + ButtonProps?: ButtonProps; /** * The content that will be shown when the component is expanded. */ @@ -54,7 +59,7 @@ export interface ShowMoreExpansionProps { } export const ShowMoreExpansion = (props: ShowMoreExpansionProps) => { - const { children, defaultExpanded, name } = props; + const { ButtonProps, children, defaultExpanded, name } = props; const { classes } = useStyles(); @@ -74,6 +79,7 @@ export const ShowMoreExpansion = (props: ShowMoreExpansionProps) => { data-qa-show-more-toggle onClick={handleNameClick} role="button" + {...ButtonProps} > {open ? ( diff --git a/packages/manager/src/components/Snackbar/CloseSnackbar.tsx b/packages/manager/src/components/Snackbar/CloseSnackbar.tsx index 7a2ae79136f..b5be1ed6653 100644 --- a/packages/manager/src/components/Snackbar/CloseSnackbar.tsx +++ b/packages/manager/src/components/Snackbar/CloseSnackbar.tsx @@ -1,5 +1,4 @@ -import { IconButton } from '@linode/ui'; -import Close from '@mui/icons-material/Close'; +import { CloseIcon, IconButton } from '@linode/ui'; import * as React from 'react'; interface Props { @@ -20,7 +19,7 @@ export const CloseSnackbar = (props: Props) => { size="large" title={text} > - + ); }; diff --git a/packages/manager/src/components/Snackbar/Snackbar.tsx b/packages/manager/src/components/Snackbar/Snackbar.tsx index d111476a7f9..a2c9efab983 100644 --- a/packages/manager/src/components/Snackbar/Snackbar.tsx +++ b/packages/manager/src/components/Snackbar/Snackbar.tsx @@ -5,14 +5,40 @@ import * as React from 'react'; import { CloseSnackbar } from './CloseSnackbar'; +import { + InfoOutlinedIcon, + TipOutlinedIcon, + WarningOutlinedIcon, + ErrorOutlinedIcon, + SuccessOutlinedIcon, +} from '@linode/ui'; + import type { Theme } from '@mui/material/styles'; import type { SnackbarProviderProps } from 'notistack'; +// Add override for "tip" variant which is Akamai specific and not built into Notistack +declare module 'notistack' { + interface VariantOverrides { + tip: true; + } +} + const StyledMaterialDesignContent = styled(MaterialDesignContent)( ({ theme }: { theme: Theme }) => ({ + '#notistack-snackbar': { + alignItems: 'flex-start', + position: 'relative', + }, + '#notistack-snackbar > svg': { + position: 'absolute', + left: '-45px', + }, '&.notistack-MuiContent': { color: theme.notificationToast.default.color, flexWrap: 'unset', + borderRadius: 0, + paddingLeft: theme.spacingFunction(12), + paddingRight: theme.spacingFunction(12), [theme.breakpoints.up('md')]: { maxWidth: '400px', }, @@ -25,7 +51,7 @@ const StyledMaterialDesignContent = styled(MaterialDesignContent)( backgroundColor: theme.notificationToast.error.backgroundColor, borderLeft: theme.notificationToast.error.borderLeft, }, - '&.notistack-MuiContent-info': { + '&.notistack-MuiContent-info, &.notistack-MuiContent-tip': { backgroundColor: theme.notificationToast.info.backgroundColor, borderLeft: theme.notificationToast.info.borderLeft, }, @@ -37,6 +63,10 @@ const StyledMaterialDesignContent = styled(MaterialDesignContent)( backgroundColor: theme.notificationToast.warning.backgroundColor, borderLeft: theme.notificationToast.warning.borderLeft, }, + '& #notistack-snackbar + div': { + alignSelf: 'flex-start', + paddingLeft: theme.spacingFunction(12), + }, }) ); @@ -50,13 +80,22 @@ export const Snackbar = (props: SnackbarProviderProps) => { return ( , + info: , + tip: , + warning: , + error: , + success: , + }} {...rest} Components={{ default: StyledMaterialDesignContent, - error: StyledMaterialDesignContent, info: StyledMaterialDesignContent, - success: StyledMaterialDesignContent, + tip: StyledMaterialDesignContent, warning: StyledMaterialDesignContent, + error: StyledMaterialDesignContent, + success: StyledMaterialDesignContent, }} action={(snackbarId) => ( ; export const Default: Story = { args: { anchorOrigin: { horizontal: 'right', vertical: 'bottom' }, - hideIconVariant: true, + hideIconVariant: false, maxSnack: 5, }, render: (args) =>