diff --git a/redisinsight/api/src/constants/error-messages.ts b/redisinsight/api/src/constants/error-messages.ts index 1769fe932a..ffe08a6bcb 100644 --- a/redisinsight/api/src/constants/error-messages.ts +++ b/redisinsight/api/src/constants/error-messages.ts @@ -27,6 +27,8 @@ export default { UNDEFINED_INSTANCE_ID: 'Undefined redis database instance id.', NO_CONNECTION_TO_REDIS_DB: 'No connection to the Redis Database.', WRONG_DATABASE_TYPE: 'Wrong database type.', + HOST_PORT_NOT_EDITABLE_FOR_MANAGED_DATABASE: + 'Host and port cannot be changed for a database managed by a cloud provider.', CONNECTION_TIMEOUT: 'The connection has timed out, please check the connection details.', DB_CONNECTION_TIMEOUT: diff --git a/redisinsight/api/src/modules/database/database.service.spec.ts b/redisinsight/api/src/modules/database/database.service.spec.ts index 075bcb0f06..6f6975d60a 100644 --- a/redisinsight/api/src/modules/database/database.service.spec.ts +++ b/redisinsight/api/src/modules/database/database.service.spec.ts @@ -1,4 +1,5 @@ import { + BadRequestException, InternalServerErrorException, NotFoundException, } from '@nestjs/common'; @@ -361,6 +362,163 @@ describe('DatabaseService', () => { ), ).rejects.toThrow(NotFoundException); }); + + describe('managed databases endpoint guard', () => { + it('should throw BadRequest when changing host of a cloud-managed database', async () => { + databaseRepository.get.mockResolvedValueOnce( + mockDatabaseWithCloudDetails, + ); + + await expect( + service.update( + mockSessionMetadata, + mockDatabase.id, + classToClass(UpdateDatabaseDto, { host: 'new-host' }), + true, + ), + ).rejects.toThrow( + new BadRequestException( + ERROR_MESSAGES.HOST_PORT_NOT_EDITABLE_FOR_MANAGED_DATABASE, + ), + ); + expect(databaseRepository.update).not.toHaveBeenCalled(); + }); + + it('should throw BadRequest when changing port of an Azure-managed database', async () => { + databaseRepository.get.mockResolvedValueOnce( + mockDatabaseWithProviderDetails, + ); + + await expect( + service.update( + mockSessionMetadata, + mockDatabase.id, + classToClass(UpdateDatabaseDto, { port: 6380 }), + true, + ), + ).rejects.toThrow( + new BadRequestException( + ERROR_MESSAGES.HOST_PORT_NOT_EDITABLE_FOR_MANAGED_DATABASE, + ), + ); + expect(databaseRepository.update).not.toHaveBeenCalled(); + }); + + it('should allow updating other fields of a managed database when the endpoint is unchanged', async () => { + databaseRepository.get.mockResolvedValueOnce( + mockDatabaseWithCloudDetails, + ); + databaseRepository.update.mockReturnValue({ + ...mockDatabaseWithCloudDetails, + name: 'new-name', + }); + + await service.update( + mockSessionMetadata, + mockDatabase.id, + classToClass(UpdateDatabaseDto, { + name: 'new-name', + host: mockDatabaseWithCloudDetails.host, + port: mockDatabaseWithCloudDetails.port, + }), + true, + ); + + expect(databaseRepository.update).toHaveBeenCalled(); + }); + + it('should allow changing host of a non-managed database', async () => { + databaseRepository.update.mockReturnValue({ + ...mockDatabase, + host: 'new-host', + }); + + await service.update( + mockSessionMetadata, + mockDatabase.id, + classToClass(UpdateDatabaseDto, { host: 'new-host' }), + true, + ); + + expect(databaseRepository.update).toHaveBeenCalled(); + }); + }); + + describe('endpoint name sync', () => { + const HOST = '127.0.100.1'; + const PORT = 6379; + + // Fresh object per call: merge mutates the returned database, and the + // shared mockDatabase can be mutated by other update tests. + const defaultNamedDatabase = () => ({ + ...mockDatabase, + host: HOST, + port: PORT, + name: `${HOST}:${PORT}`, + }); + + it('should sync the name to the new endpoint when the name was the default host:port', async () => { + databaseRepository.get.mockResolvedValueOnce(defaultNamedDatabase()); + + await service.update( + mockSessionMetadata, + mockDatabase.id, + classToClass(UpdateDatabaseDto, { host: 'new-host' }), + true, + ); + + expect(databaseFactory.createDatabaseModel).toHaveBeenCalledWith( + mockSessionMetadata, + expect.objectContaining({ + host: 'new-host', + name: `new-host:${PORT}`, + }), + ); + }); + + it('should not override an explicit name provided in the update', async () => { + databaseRepository.get.mockResolvedValueOnce(defaultNamedDatabase()); + + await service.update( + mockSessionMetadata, + mockDatabase.id, + classToClass(UpdateDatabaseDto, { + host: 'new-host', + name: 'custom-name', + }), + true, + ); + + expect(databaseFactory.createDatabaseModel).toHaveBeenCalledWith( + mockSessionMetadata, + expect.objectContaining({ host: 'new-host', name: 'custom-name' }), + ); + }); + + it('should not change a custom name when the endpoint changes', async () => { + databaseRepository.get.mockResolvedValueOnce({ + ...mockDatabase, + host: HOST, + port: PORT, + name: 'my-custom-alias', + }); + + await service.update( + mockSessionMetadata, + mockDatabase.id, + classToClass(UpdateDatabaseDto, { host: 'new-host' }), + true, + ); + + expect(databaseFactory.createDatabaseModel).toHaveBeenCalledWith( + mockSessionMetadata, + expect.objectContaining({ + host: 'new-host', + name: 'my-custom-alias', + }), + ); + }); + }); }); describe('test', () => { diff --git a/redisinsight/api/src/modules/database/database.service.ts b/redisinsight/api/src/modules/database/database.service.ts index a41fa63211..f17198c030 100644 --- a/redisinsight/api/src/modules/database/database.service.ts +++ b/redisinsight/api/src/modules/database/database.service.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Injectable, InternalServerErrorException, Logger, @@ -85,6 +86,39 @@ export class DatabaseService { ); } + /** + * Checks whether the endpoint (host/port) in the dto differs from the stored one. + * Unlike isEndpointAffected, this compares values so an unchanged host/port + * present in the payload is not treated as a change. + */ + static isEndpointChanged( + dto: UpdateDatabaseDto, + database: Database, + ): boolean { + return ( + (dto.host !== undefined && dto.host !== database.host) || + (dto.port !== undefined && dto.port !== database.port) + ); + } + + /** + * A database is considered managed when its endpoint is owned by a cloud + * provider (Redis Cloud subscription or Azure). For such databases the + * host/port are tied to provider metadata (cloudDetails/providerDetails) that + * would become stale if the endpoint were edited manually. + */ + static isManagedDatabase(database: Database): boolean { + return !!database.cloudDetails?.cloudId || !!database.providerDetails; + } + + /** + * Whether the database name is still the default "host:port" derived from its + * current endpoint (i.e. the user never set a custom alias). + */ + static hasDefaultEndpointName(database: Database): boolean { + return database.name === `${database.host}:${database.port}`; + } + private async merge( database: Database, dto: UpdateDatabaseDto, @@ -246,10 +280,34 @@ export class DatabaseService { this.logger.debug(`Updating database: ${id}`, sessionMetadata); const oldDatabase = await this.get(sessionMetadata, id, true); + if ( + DatabaseService.isEndpointChanged(dto, oldDatabase) && + DatabaseService.isManagedDatabase(oldDatabase) + ) { + throw new BadRequestException( + ERROR_MESSAGES.HOST_PORT_NOT_EDITABLE_FOR_MANAGED_DATABASE, + ); + } + + // When the name is still the default "host:port" and the endpoint changes, + // keep the name in sync with the new endpoint. Computed before merge (which + // mutates oldDatabase) and skipped when the caller sets a name explicitly or + // the user has a custom alias. + const syncedName = + dto.name === undefined && + DatabaseService.isEndpointChanged(dto, oldDatabase) && + DatabaseService.hasDefaultEndpointName(oldDatabase) + ? `${dto.host ?? oldDatabase.host}:${dto.port ?? oldDatabase.port}` + : undefined; + let database: Database; try { database = await this.merge(oldDatabase, dto); + if (syncedName !== undefined) { + database.name = syncedName; + } + if (DatabaseService.isConnectionAffected(dto)) { if (DatabaseService.isEndpointAffected(dto)) { database.provider = undefined; diff --git a/redisinsight/api/test/api/database/PATCH-databases-id.test.ts b/redisinsight/api/test/api/database/PATCH-databases-id.test.ts index c60ed029f2..54648b04a5 100644 --- a/redisinsight/api/test/api/database/PATCH-databases-id.test.ts +++ b/redisinsight/api/test/api/database/PATCH-databases-id.test.ts @@ -9,6 +9,7 @@ import { _, it, validateApiCall, + before, after, } from '../deps'; import { Joi } from '../../helpers/test'; @@ -253,6 +254,95 @@ describe(`PATCH /databases/:id`, () => { }, ].map(mainCheckFn); }); + describe('Managed databases (cloud) endpoint guard', () => { + const managedHostPortMessage = + 'Host and port cannot be changed for a database managed by a cloud provider.'; + // Dedicated managed instance so we never mutate the shared TEST_INSTANCE_ID + // (its cloudDetails would otherwise leak into later tests). + const MANAGED_ID = 'cloud0000-0000-4000-8000-managed000001'; + const managedEndpoint = () => endpoint(MANAGED_ID); + const managedName = constants.getRandomString(); + + // Seed once directly via the repository: cloudDetails marks the database as + // managed, and the guard rejects endpoint changes before any connection, so + // no real connectivity is required. + before(async () => { + const rep = await localDb.getRepository(localDb.repositories.DATABASE); + await rep.save({ + id: MANAGED_ID, + name: 'cloud-managed-db', + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + connectionType: constants.STANDALONE, + tls: false, + verifyServerCert: false, + modules: '[]', + version: '7.0', + cloudDetails: { + cloudId: constants.TEST_CLOUD_ID, + subscriptionType: 'fixed', + }, + }); + }); + + after(async () => { + const rep = await localDb.getRepository(localDb.repositories.DATABASE); + await rep.delete(MANAGED_ID); + }); + + [ + { + name: 'Should reject host change for a cloud-managed database', + endpoint: managedEndpoint, + data: { + host: constants.getRandomString(), + }, + statusCode: 400, + responseBody: { + statusCode: 400, + error: 'Bad Request', + message: managedHostPortMessage, + }, + after: async () => { + // endpoint must remain unchanged + const db = await localDb.getInstanceById(MANAGED_ID); + expect(db?.host).to.eq(constants.TEST_REDIS_HOST); + expect(db?.port).to.eq(constants.TEST_REDIS_PORT); + }, + }, + { + name: 'Should reject port change for a cloud-managed database', + endpoint: managedEndpoint, + data: { + port: 1234, + }, + statusCode: 400, + responseBody: { + statusCode: 400, + error: 'Bad Request', + message: managedHostPortMessage, + }, + after: async () => { + const db = await localDb.getInstanceById(MANAGED_ID); + expect(db?.port).to.eq(constants.TEST_REDIS_PORT); + }, + }, + { + name: 'Should allow non-endpoint change (name) for a cloud-managed database', + endpoint: managedEndpoint, + data: { + name: managedName, + }, + responseBody: { + name: managedName, + }, + after: async () => { + const db = await localDb.getInstanceById(MANAGED_ID); + expect(db?.name).to.eq(managedName); + }, + }, + ].map(mainCheckFn); + }); describe('TAGS', () => { const newTagsDto1 = [ { diff --git a/redisinsight/ui/src/pages/home/components/form/DbInfo.tsx b/redisinsight/ui/src/pages/home/components/form/DbInfo.tsx index f1cd52e092..9c2a4cbfd3 100644 --- a/redisinsight/ui/src/pages/home/components/form/DbInfo.tsx +++ b/redisinsight/ui/src/pages/home/components/form/DbInfo.tsx @@ -26,6 +26,7 @@ export interface Props { db: Nullable modules: AdditionalRedisModule[] isFromCloud: boolean + isManaged?: boolean } export const ListGroupItemLabelValue = ({ @@ -91,8 +92,14 @@ const DbInfo = (props: Props) => { db, modules, isFromCloud, + isManaged = false, } = props + // The endpoint is editable in the form for non-managed, non-cloud databases, + // so it is hidden from this read-only summary in that case and shown here + // otherwise (cloud/managed databases keep a read-only endpoint). + const isEndpointEditable = !isManaged && !isFromCloud + const { server } = useAppSelector(appInfoSelector) const dbInfo: DbInfoLabelValue[] = [ @@ -112,6 +119,7 @@ const DbInfo = (props: Props) => { label: 'Host:', value: host, dataTestId: 'db-info-host', + hide: isEndpointEditable && !nodes?.length, additionalContent: !!nodes?.length && ( ), @@ -120,7 +128,7 @@ const DbInfo = (props: Props) => { label: 'Port:', value: port, dataTestId: 'db-info-port', - hide: server?.buildType !== BuildType.RedisStack && !isFromCloud, + hide: server?.buildType !== BuildType.RedisStack && isEndpointEditable, }, { label: 'Database Index:', diff --git a/redisinsight/ui/src/pages/home/components/manual-connection/ManualConnectionWrapper.tsx b/redisinsight/ui/src/pages/home/components/manual-connection/ManualConnectionWrapper.tsx index 5c36bb1931..fe1db2d1a0 100644 --- a/redisinsight/ui/src/pages/home/components/manual-connection/ManualConnectionWrapper.tsx +++ b/redisinsight/ui/src/pages/home/components/manual-connection/ManualConnectionWrapper.tsx @@ -18,6 +18,7 @@ import { transformQueryParamsObject, getDiffKeysOfObjectValues, isAzureDatabase, + isManagedDatabase, } from 'uiSrc/utils' import { BuildType } from 'uiSrc/constants/env' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' @@ -313,6 +314,7 @@ const ManualConnectionWrapper = (props: Props) => { ) const isFromAzure = isAzureDatabase(editedInstance) + const isManaged = isManagedDatabase(editedInstance) return ( { onAliasEdited={onAliasEdited} onClickBack={onClickBack} isFromAzure={isFromAzure} + isManaged={isManaged} /> ) } diff --git a/redisinsight/ui/src/pages/home/components/manual-connection/manual-connection-form/ManualConnectionForm.tsx b/redisinsight/ui/src/pages/home/components/manual-connection/manual-connection-form/ManualConnectionForm.tsx index 175d35d12f..af0b6da5e2 100644 --- a/redisinsight/ui/src/pages/home/components/manual-connection/manual-connection-form/ManualConnectionForm.tsx +++ b/redisinsight/ui/src/pages/home/components/manual-connection/manual-connection-form/ManualConnectionForm.tsx @@ -46,6 +46,7 @@ export interface Props { isEditMode: boolean isCloneMode: boolean isFromAzure?: boolean + isManaged?: boolean setIsCloneMode: (value: boolean) => void onSubmit: (values: DbConnectionInfo) => void onTestConnection: (values: DbConnectionInfo) => void @@ -74,6 +75,7 @@ const ManualConnectionForm = (props: Props) => { isCloneMode, setIsCloneMode, isFromAzure = false, + isManaged = false, } = props const { @@ -275,6 +277,7 @@ const ManualConnectionForm = (props: Props) => { nameFromProvider={nameFromProvider} nodes={nodes} isFromCloud={isFromCloud} + isManaged={isManaged} /> @@ -288,6 +291,7 @@ const ManualConnectionForm = (props: Props) => { isEditMode={isEditMode} isFromCloud={isFromCloud} isFromAzure={isFromAzure} + isManaged={isManaged} formik={formik} onKeyDown={onKeyDown} onHostNamePaste={onHostNamePaste} diff --git a/redisinsight/ui/src/pages/home/components/manual-connection/manual-connection-form/ManualConnectionFrom.spec.tsx b/redisinsight/ui/src/pages/home/components/manual-connection/manual-connection-form/ManualConnectionFrom.spec.tsx index 1269b0caf6..09fe135917 100644 --- a/redisinsight/ui/src/pages/home/components/manual-connection/manual-connection-form/ManualConnectionFrom.spec.tsx +++ b/redisinsight/ui/src/pages/home/components/manual-connection/manual-connection-form/ManualConnectionFrom.spec.tsx @@ -106,6 +106,32 @@ describe('InstanceForm', () => { ).toBeTruthy() }) + it('should show the host input when editing a non-managed database', () => { + render( + , + ) + + expect(screen.getByTestId('host')).toBeInTheDocument() + }) + + it('should hide the host input when editing a managed database', () => { + render( + , + ) + + expect(screen.queryByTestId('host')).not.toBeInTheDocument() + }) + it('should render DatabaseForm', () => { expect( render( @@ -1328,12 +1354,13 @@ describe('InstanceForm', () => { })) }) - it('should disable connection fields when editing Azure database', () => { + it('should hide the endpoint and disable credentials when editing Azure database', () => { render( { />, ) - // Host is not shown in edit mode (shown as info above form) + // Endpoint (host/port) is hidden for managed databases and shown as + // read-only info above the form expect(screen.queryByTestId('host')).not.toBeInTheDocument() - // Port, username, password should be disabled - expect(screen.getByTestId('port')).toBeDisabled() + expect(screen.queryByTestId('port')).not.toBeInTheDocument() + // Username and password remain visible but disabled for Azure expect(screen.getByTestId('username')).toBeDisabled() expect(screen.getByTestId('password')).toBeDisabled() }) @@ -1369,7 +1397,7 @@ describe('InstanceForm', () => { expect(screen.getByTestId('password')).toBeDisabled() }) - it('should not disable connection fields for non-Azure database in edit mode', () => { + it('should show and enable the endpoint for a non-managed database in edit mode', () => { render( { />, ) - // Host is not shown in edit mode (shown as info above form) - expect(screen.queryByTestId('host')).not.toBeInTheDocument() - // Port, username, password should NOT be disabled for non-Azure databases + // Host and port are editable for non-managed databases in edit mode + expect(screen.getByTestId('host')).not.toBeDisabled() expect(screen.getByTestId('port')).not.toBeDisabled() expect(screen.getByTestId('username')).not.toBeDisabled() expect(screen.getByTestId('password')).not.toBeDisabled() diff --git a/redisinsight/ui/src/pages/home/components/manual-connection/manual-connection-form/forms/EditConnection.tsx b/redisinsight/ui/src/pages/home/components/manual-connection/manual-connection-form/forms/EditConnection.tsx index f0aafe5be0..74fb8ffc80 100644 --- a/redisinsight/ui/src/pages/home/components/manual-connection/manual-connection-form/forms/EditConnection.tsx +++ b/redisinsight/ui/src/pages/home/components/manual-connection/manual-connection-form/forms/EditConnection.tsx @@ -24,6 +24,7 @@ export interface Props { isCloneMode: boolean isFromCloud: boolean isFromAzure?: boolean + isManaged?: boolean formik: FormikProps onKeyDown: (event: React.KeyboardEvent) => void onHostNamePaste: (content: string) => boolean @@ -39,6 +40,7 @@ const EditConnection = (props: Props) => { isEditMode, isFromCloud, isFromAzure = false, + isManaged = false, formik, onKeyDown, onHostNamePaste, @@ -50,6 +52,12 @@ const EditConnection = (props: Props) => { // For Azure databases in edit/clone mode, disable connection fields const readOnlyFields = isFromAzure && isEditMode ? AZURE_READONLY_FIELDS : [] + // The endpoint (host/port) is editable when adding, cloning, or editing a + // non-managed database. For cloud-managed databases it stays read-only since + // the endpoint is tied to provider metadata (see isManagedDatabase). + const showEndpointFields = + (!isEditMode || isCloneMode || !isManaged) && !isFromCloud + return (
{ formik={formik} showFields={{ alias: true, - host: (!isEditMode || isCloneMode) && !isFromCloud, - port: !isFromCloud, + host: showEndpointFields, + port: showEndpointFields, timeout: true, }} autoFocus={!isCloneMode && isEditMode} diff --git a/redisinsight/ui/src/utils/instance/instanceProvider.ts b/redisinsight/ui/src/utils/instance/instanceProvider.ts index 281f1178e4..c0d9e920d0 100644 --- a/redisinsight/ui/src/utils/instance/instanceProvider.ts +++ b/redisinsight/ui/src/utils/instance/instanceProvider.ts @@ -12,3 +12,14 @@ export const isAzureDatabase = ( return instance.providerDetails.provider === AZURE_PROVIDER } + +/** + * A database is "managed" when its endpoint is owned by a cloud provider + * (Redis Cloud subscription or Azure). For such databases the host/port are + * tied to provider metadata that would become stale if edited manually, so the + * endpoint must stay read-only. Mirrors DatabaseService.isManagedDatabase on + * the backend. + */ +export const isManagedDatabase = ( + instance: Nullable>, +): boolean => isAzureDatabase(instance) || !!instance?.cloudDetails?.cloudId diff --git a/redisinsight/ui/src/utils/tests/instance/instanceProvider.spec.ts b/redisinsight/ui/src/utils/tests/instance/instanceProvider.spec.ts index e8c35c7c5f..2909a8b583 100644 --- a/redisinsight/ui/src/utils/tests/instance/instanceProvider.spec.ts +++ b/redisinsight/ui/src/utils/tests/instance/instanceProvider.spec.ts @@ -1,4 +1,4 @@ -import { isAzureDatabase } from 'uiSrc/utils' +import { isAzureDatabase, isManagedDatabase } from 'uiSrc/utils' import { DBInstanceFactory } from 'uiSrc/mocks/factories/database/DBInstance.factory' // "as any" is used for providerDetails because Instance extends Partial @@ -58,3 +58,45 @@ describe('isAzureDatabase', () => { expect(isAzureDatabase({})).toBe(false) }) }) + +describe('isManagedDatabase', () => { + it('should return true for an Azure database', () => { + const instance = DBInstanceFactory.build({ + providerDetails: { + provider: 'azure', + authType: 'entraId', + } as any, + }) + + expect(isManagedDatabase(instance)).toBe(true) + }) + + it('should return true when the database has cloudDetails with a cloudId', () => { + const instance = DBInstanceFactory.build({ + cloudDetails: { cloudId: 12345 } as any, + }) + + expect(isManagedDatabase(instance)).toBe(true) + }) + + it('should return false for a plain database without cloud/provider metadata', () => { + const instance = DBInstanceFactory.build({ + providerDetails: undefined, + cloudDetails: undefined, + }) + + expect(isManagedDatabase(instance)).toBe(false) + }) + + it('should return false when cloudDetails has no cloudId', () => { + const instance = DBInstanceFactory.build({ + cloudDetails: {} as any, + }) + + expect(isManagedDatabase(instance)).toBe(false) + }) + + it('should return false when instance is null', () => { + expect(isManagedDatabase(null)).toBe(false) + }) +}) diff --git a/tests/e2e-playwright/TEST_PLAN.md b/tests/e2e-playwright/TEST_PLAN.md index 684d2f4afd..598ecd79a5 100644 --- a/tests/e2e-playwright/TEST_PLAN.md +++ b/tests/e2e-playwright/TEST_PLAN.md @@ -144,6 +144,12 @@ The test plan is organized by feature area. Tests are grouped for parallel execu | ✅ | main | Development DB > should show DEV label in databases list and instance header | | ✅ | main | Unspecified DB > should not render an environment badge in list or header | +### 1.2.1 Edit Database +| Status | Group | Test Case | +|--------|-------|-----------| +| ✅ | main | Host field is editable when editing a non-managed database | +| ✅ | main | Host field is read-only when editing a cloud-managed database | + ### 1.3 Clone Database | Status | Group | Test Case | |--------|-------|-----------| diff --git a/tests/e2e-playwright/helpers/api.ts b/tests/e2e-playwright/helpers/api.ts index e659ff6bc8..1d3c0b43f8 100644 --- a/tests/e2e-playwright/helpers/api.ts +++ b/tests/e2e-playwright/helpers/api.ts @@ -43,6 +43,7 @@ export class ApiHelper { password: config.password || null, db: config.db ?? 0, ...(config.environment ? { environment: config.environment } : {}), + ...(config.cloudDetails ? { cloudDetails: config.cloudDetails } : {}), }, }); diff --git a/tests/e2e-playwright/pages/databases/components/AddDatabaseDialog.ts b/tests/e2e-playwright/pages/databases/components/AddDatabaseDialog.ts index 68a647b974..cc767f6cac 100644 --- a/tests/e2e-playwright/pages/databases/components/AddDatabaseDialog.ts +++ b/tests/e2e-playwright/pages/databases/components/AddDatabaseDialog.ts @@ -25,6 +25,11 @@ export class AddDatabaseDialog { readonly testConnectionButton: Locator; readonly dialog: Locator; + // Read-only endpoint info (shown in edit mode instead of editable fields + // for cloud/managed databases) + readonly dbInfoHost: Locator; + readonly dbInfoPort: Locator; + // Additional settings readonly timeoutInput: Locator; readonly selectLogicalDatabaseCheckbox: Locator; @@ -81,6 +86,10 @@ export class AddDatabaseDialog { this.cancelButton = page.getByRole('button', { name: 'Cancel' }); this.testConnectionButton = page.getByRole('button', { name: 'Test Connection' }); + // Read-only endpoint info + this.dbInfoHost = page.getByTestId('db-info-host'); + this.dbInfoPort = page.getByTestId('db-info-port'); + // Connection settings form this.databaseAliasInput = page.getByPlaceholder('Enter Database Alias'); this.hostInput = page.getByPlaceholder('Enter Hostname / IP address / Connection URL'); diff --git a/tests/e2e-playwright/tests/parallel/databases/edit/host.spec.ts b/tests/e2e-playwright/tests/parallel/databases/edit/host.spec.ts new file mode 100644 index 0000000000..e9357b1dd5 --- /dev/null +++ b/tests/e2e-playwright/tests/parallel/databases/edit/host.spec.ts @@ -0,0 +1,78 @@ +import { test, expect } from 'e2eSrc/fixtures/base'; +import { StandaloneConfigFactory, StandaloneEmptyConfigFactory } from 'e2eSrc/test-data/databases'; +import { DatabaseInstance } from 'e2eSrc/types'; +import { faker } from '@faker-js/faker'; + +/** + * Databases > Edit > Host field + * + * Guards the host-editability rules end-to-end: + * - Non-managed databases expose an editable host field in edit mode. + * - Cloud-managed databases (carrying cloudDetails) keep the endpoint + * read-only: the host field is hidden and shown only as read-only info. + * + * The backend guard that rejects endpoint changes for managed databases is + * covered by the API integration tests (PATCH /databases/:id); here we verify + * the user-facing form wiring that the unit tests cannot exercise full-stack. + */ +test.describe('Databases > Edit > Host field', () => { + let standaloneDb: DatabaseInstance; + let managedDb: DatabaseInstance | undefined; + + test.beforeAll(async ({ apiHelper }) => { + standaloneDb = await apiHelper.createDatabase(StandaloneConfigFactory.build()); + + // A managed database is a real (reachable) database that additionally + // carries cloudDetails. Use the isolated "empty" instance to avoid the + // cloud uniqueness check colliding with other tests' databases on the + // primary standalone endpoint. + try { + const managedConfig = StandaloneEmptyConfigFactory.build({ + name: `test-managed-${faker.string.alphanumeric(8)}`, + cloudDetails: { cloudId: faker.number.int({ min: 100000, max: 999999 }), subscriptionType: 'fixed' }, + }); + managedDb = await apiHelper.createDatabase(managedConfig); + } catch { + // Managed fixture unavailable in this environment - the managed test is skipped + } + }); + + test.afterAll(async ({ apiHelper }) => { + for (const db of [standaloneDb, managedDb]) { + if (db?.id) { + await apiHelper.deleteDatabase(db.id).catch(() => {}); + } + } + }); + + test.beforeEach(async ({ databasesPage }) => { + await databasesPage.goto(); + }); + + test('should expose an editable host field when editing a non-managed database', async ({ databasesPage }) => { + const { databaseList, addDatabaseDialog } = databasesPage; + + await databaseList.expectDatabaseVisible(standaloneDb.name, { searchFirst: true }); + await databaseList.edit(standaloneDb.name); + + await expect(addDatabaseDialog.dialog).toBeVisible(); + await expect(addDatabaseDialog.hostInput).toBeVisible(); + await expect(addDatabaseDialog.hostInput).toHaveValue(standaloneDb.host); + await expect(addDatabaseDialog.hostInput).toBeEditable(); + }); + + test('should keep the host field read-only when editing a cloud-managed database', async ({ databasesPage }) => { + test.skip(!managedDb, 'Managed database fixture unavailable in this environment'); + + const { databaseList, addDatabaseDialog } = databasesPage; + + await databaseList.expectDatabaseVisible(managedDb!.name, { searchFirst: true }); + await databaseList.edit(managedDb!.name); + + await expect(addDatabaseDialog.dialog).toBeVisible(); + // Endpoint is not editable for managed databases: the host input is absent, + // and the endpoint is surfaced as read-only info instead. + await expect(addDatabaseDialog.hostInput).toHaveCount(0); + await expect(addDatabaseDialog.dbInfoHost).toBeVisible(); + }); +}); diff --git a/tests/e2e-playwright/types/database.ts b/tests/e2e-playwright/types/database.ts index a9bf62e510..eeb703056a 100644 --- a/tests/e2e-playwright/types/database.ts +++ b/tests/e2e-playwright/types/database.ts @@ -30,11 +30,22 @@ export interface RedisConnectionConfig { environment?: Environment; } +/** + * Cloud (Redis Cloud) subscription metadata that marks a database as managed. + * When present on a created database, the app treats its endpoint as owned by + * the cloud provider and keeps host/port read-only. + */ +export interface CloudDatabaseDetailsConfig { + cloudId: number; + subscriptionType: 'fixed' | 'flexible'; +} + /** * Configuration for adding a database via UI */ export interface AddDatabaseConfig extends RedisConnectionConfig { name: string; + cloudDetails?: CloudDatabaseDetailsConfig; } /**