diff --git a/frontend/e2e/mocks/secrets/asciisecret.txt b/frontend/e2e/mocks/secrets/asciisecret.txt new file mode 100644 index 00000000000..7c3fb9387ab --- /dev/null +++ b/frontend/e2e/mocks/secrets/asciisecret.txt @@ -0,0 +1 @@ +This is ascii \ No newline at end of file diff --git a/frontend/e2e/mocks/secrets/binarysecret.bin b/frontend/e2e/mocks/secrets/binarysecret.bin new file mode 100644 index 00000000000..994e78bd5b2 Binary files /dev/null and b/frontend/e2e/mocks/secrets/binarysecret.bin differ diff --git a/frontend/e2e/mocks/secrets/unicodesecret.utf8 b/frontend/e2e/mocks/secrets/unicodesecret.utf8 new file mode 100644 index 00000000000..aaaf48d6610 --- /dev/null +++ b/frontend/e2e/mocks/secrets/unicodesecret.utf8 @@ -0,0 +1 @@ +ȹȻɣʄɣɱɰ \ No newline at end of file diff --git a/frontend/e2e/pages/secrets-page.ts b/frontend/e2e/pages/secrets-page.ts new file mode 100644 index 00000000000..b248605f790 --- /dev/null +++ b/frontend/e2e/pages/secrets-page.ts @@ -0,0 +1,219 @@ +import type { Locator } from '@playwright/test'; + +import { expect } from '../fixtures'; + +import BasePage from './base-page'; +import { ModalPage } from './modal-page'; + +export class SecretsPage extends BasePage { + private readonly modal = new ModalPage(this.page); + private readonly secretNameInput = this.page.getByTestId('secret-name'); + private readonly secretKeyInput = this.page.getByTestId('secret-key'); + private readonly saveButton = this.page.getByTestId('save-changes'); + private readonly binaryAlert = this.page.getByTestId('file-input-binary-alert'); + private readonly fileInputTextarea = this.page.getByTestId('file-input-textarea'); + private readonly addCredentialsButton = this.page.getByTestId('add-credentials-button'); + private readonly removeEntryButton = this.page.getByTestId('remove-entry-button'); + private readonly revealValuesButton = this.page.getByTestId('reveal-values'); + private readonly secretData = this.page.getByTestId('secret-data'); + private readonly secretDataTerm = this.page.getByTestId('secret-data-term'); + private readonly copyToClipboard = this.page.getByTestId('copy-to-clipboard'); + private readonly pageHeading = this.page.getByTestId('page-heading').locator('h1'); + + private readonly credentialForm = this.page.getByTestId('create-image-secret-form'); + private readonly authTypeToggle = this.page.getByTestId('console-select-auth-type-menu-toggle'); + private readonly addressInput = this.page.getByTestId('image-secret-address'); + private readonly usernameInput = this.page.getByTestId('image-secret-username'); + private readonly passwordInput = this.page.getByTestId('image-secret-password'); + private readonly emailInput = this.page.getByTestId('image-secret-email'); + private readonly secretPasswordInput = this.page.getByTestId('secret-password'); + + async navigateToCreateGenericSecret(namespace: string): Promise { + await this.goTo(`/k8s/ns/${namespace}/secrets/~new/generic`); + } + + async navigateToEditSecret(namespace: string, secretName: string): Promise { + await this.goTo(`/k8s/ns/${namespace}/secrets/${secretName}/edit`); + } + + async navigateToSecretDetails(namespace: string, secretName: string): Promise { + await this.goTo(`/k8s/ns/${namespace}/secrets/${secretName}`); + } + + async enterSecretName(name: string): Promise { + await this.secretNameInput.fill(name); + } + + async fillSecretKey(key: string, index = 0): Promise { + await this.secretKeyInput.nth(index).fill(key); + } + + async uploadFile(filePath: string): Promise { + await this.page.locator('input[type="file"]').first().setInputFiles(filePath); + } + + async save(): Promise { + await this.robustClick(this.saveButton); + // eslint-disable-next-line no-restricted-syntax + await this.saveButton.waitFor({ state: 'detached', timeout: 30_000 }); + } + + async addKeyValue(key: string, value: string): Promise { + await this.robustClick(this.addCredentialsButton); + await this.secretKeyInput.last().fill(key); + await this.fileInputTextarea.last().fill(value); + } + + async waitForSecretDataReady(): Promise { + await this.waitForLoadingComplete(); + const tryAgain = this.page.getByRole('button', { name: 'Try again' }); + for (let attempt = 0; attempt < 5; attempt++) { + if (await tryAgain.isVisible({ timeout: 2_000 }).catch(() => false)) { + await tryAgain.click(); + await this.waitForLoadingComplete(); + continue; + } + break; + } + const dataOrEmpty = this.secretData.or(this.page.locator('.pf-v6-c-empty-state')); + await expect(dataOrEmpty.first()).toBeVisible({ timeout: 30_000 }); + } + + async clickRevealValues(): Promise { + await this.waitForSecretDataReady(); + await this.robustClick(this.revealValuesButton); + } + + getPageHeading(): Locator { + return this.pageHeading; + } + + getBinaryAlert(): Locator { + return this.binaryAlert; + } + + getFileInputTextarea(): Locator { + return this.fileInputTextarea; + } + + getSecretData(): Locator { + return this.secretData; + } + + getSecretDataTerm(): Locator { + return this.secretDataTerm; + } + + getCopyToClipboard(): Locator { + return this.copyToClipboard; + } + + getSecretKeyInput(): Locator { + return this.secretKeyInput; + } + + async navigateToCreateImagePullSecret(namespace: string): Promise { + await this.goTo(`/k8s/ns/${namespace}/secrets/~new/image`); + } + + async navigateToCreateSourceSecret(namespace: string): Promise { + await this.goTo(`/k8s/ns/${namespace}/secrets/~new/source`); + } + + async navigateToCreateWebhookSecret(namespace: string): Promise { + await this.goTo(`/k8s/ns/${namespace}/secrets/~new/webhook`); + } + + async clickAddCredentials(): Promise { + await this.robustClick(this.addCredentialsButton); + } + + async clickRemoveFirstEntry(): Promise { + await this.robustClick(this.removeEntryButton.first()); + } + + async fillCredentialEntry( + index: number, + address: string, + username: string, + password: string, + email: string, + ): Promise { + const form = this.credentialForm.nth(index); + await form.getByTestId('image-secret-address').fill(address); + await form.getByTestId('image-secret-username').fill(username); + await form.getByTestId('image-secret-password').fill(password); + await form.getByTestId('image-secret-email').fill(email); + } + + async selectAuthType(type: string): Promise { + await this.robustClick(this.authTypeToggle); + await this.robustClick(this.page.getByTestId(`dropdown-menu-${type}`)); + } + + async checkSecretData(expected: Record, jsonOutput = false): Promise { + await this.clickRevealValues(); + const terms = this.secretDataTerm; + const values = this.copyToClipboard; + const termCount = await terms.count(); + const rendered: Record = {}; + for (let i = 0; i < termCount; i++) { + const key = (await terms.nth(i).textContent()) ?? ''; + const val = (await values.nth(i).textContent()) ?? ''; + rendered[key] = jsonOutput ? JSON.parse(val) : val; + } + expect(rendered).toEqual(expected); + } + + getCredentialForm(): Locator { + return this.credentialForm; + } + + getAddressInput(): Locator { + return this.addressInput; + } + + getUsernameInput(): Locator { + return this.usernameInput; + } + + getPasswordInput(): Locator { + return this.passwordInput; + } + + getEmailInput(): Locator { + return this.emailInput; + } + + getSecretPasswordInput(): Locator { + return this.secretPasswordInput; + } + + async addToWorkload( + workloadName: string, + asType: 'environment' | 'volume', + options?: { prefix?: string; mountPath?: string }, + ): Promise { + await this.robustClick(this.page.getByTestId('Add Secret to workload')); + await this.modal.waitForOpen(); + + await this.robustClick(this.page.getByTestId('add-secret-to-workload-button')); + await this.page.getByTestId('console-select-search-input').locator('input').fill(workloadName); + await this.page.getByTestId('console-select-item').click(); + + if (asType === 'environment') { + await this.page.getByTestId('Environment variables-radio-input').click(); + if (options?.prefix) { + await this.page.getByTestId('add-secret-to-workload-prefix').fill(options.prefix); + } + } else { + await this.page.getByTestId('Volume-radio-input').click(); + if (options?.mountPath) { + await this.page.getByTestId('add-secret-to-workload-mountpath').fill(options.mountPath); + } + } + + await this.modal.submit(); + await this.modal.waitForClosed(); + } +} diff --git a/frontend/e2e/tests/console/crud/secrets/add-to-workload.spec.ts b/frontend/e2e/tests/console/crud/secrets/add-to-workload.spec.ts new file mode 100644 index 00000000000..f9517aa4f26 --- /dev/null +++ b/frontend/e2e/tests/console/crud/secrets/add-to-workload.spec.ts @@ -0,0 +1,99 @@ +import { test, expect } from '../../../../fixtures'; +import { warmupSPA } from '../../../../pages/base-page'; +import { DetailsPage } from '../../../../pages/details-page'; +import { SecretsPage } from '../../../../pages/secrets-page'; + +test.describe('Add Secret to Workloads', () => { + let namespace: string; + const secretName = 'test-secret'; + const resourceName = 'test-deploy'; + const envPrefix = 'env-'; + const mountPath = '/tmp/testdata'; + + test.beforeAll(async ({ k8sClient }) => { + namespace = `test-add-to-wl-${Date.now()}`; + await k8sClient.createNamespace(namespace); + await k8sClient.waitForNamespaceReady(namespace); + + await k8sClient.createDeployment(namespace, { + apiVersion: 'apps/v1', + kind: 'Deployment', + metadata: { name: resourceName, namespace }, + spec: { + selector: { matchLabels: { test: 'add-secret-to-workload' } }, + template: { + metadata: { labels: { test: 'add-secret-to-workload' } }, + spec: { + containers: [ + { + name: 'httpd', + image: + 'image-registry.openshift-image-registry.svc:5000/openshift/httpd:latest', + }, + ], + }, + }, + }, + }); + + await k8sClient.createSecret(secretName, namespace, { + key1: Buffer.from('supersecret').toString('base64'), + }); + }); + + test.afterAll(async ({ k8sClient }) => { + await k8sClient.deleteNamespace(namespace); + }); + + test('adds secret to deployment as environment variables', async ({ page, k8sClient }) => { + const secretsPage = new SecretsPage(page); + const detailsPage = new DetailsPage(page); + + await test.step('Navigate to secret details', async () => { + await warmupSPA(page); + await secretsPage.navigateToSecretDetails(namespace, secretName); + await detailsPage.waitForPageLoad(); + }); + + await test.step('Add secret as env vars via modal', async () => { + await secretsPage.addToWorkload(resourceName, 'environment', { prefix: envPrefix }); + }); + + await test.step('Verify deployment has envFrom', async () => { + const deployment = await k8sClient.appsV1Api.readNamespacedDeployment({ + name: resourceName, + namespace, + }); + const envFrom = deployment.spec?.template?.spec?.containers?.[0]?.envFrom?.[0]; + expect(envFrom?.secretRef?.name).toBe(secretName); + expect(envFrom?.prefix).toBe(envPrefix); + }); + }); + + test('adds secret to deployment as volume', async ({ page, k8sClient }) => { + const secretsPage = new SecretsPage(page); + const detailsPage = new DetailsPage(page); + + await test.step('Navigate to secret details', async () => { + await warmupSPA(page); + await secretsPage.navigateToSecretDetails(namespace, secretName); + await detailsPage.waitForPageLoad(); + }); + + await test.step('Add secret as volume via modal', async () => { + await secretsPage.addToWorkload(resourceName, 'volume', { mountPath }); + }); + + await test.step('Verify deployment has volumeMount', async () => { + const deployment = await k8sClient.appsV1Api.readNamespacedDeployment({ + name: resourceName, + namespace, + }); + const volumeMount = deployment.spec?.template?.spec?.containers?.[0]?.volumeMounts?.find( + (vm) => vm.name === secretName, + ); + expect(volumeMount?.name).toBe(secretName); + expect(volumeMount?.mountPath).toBe(mountPath); + }); + }); +}); diff --git a/frontend/e2e/tests/console/crud/secrets/image-pull.spec.ts b/frontend/e2e/tests/console/crud/secrets/image-pull.spec.ts new file mode 100644 index 00000000000..056ef40e88a --- /dev/null +++ b/frontend/e2e/tests/console/crud/secrets/image-pull.spec.ts @@ -0,0 +1,175 @@ +import { Base64 } from 'js-base64'; + +import { test, expect } from '../../../../fixtures'; +import { DetailsPage } from '../../../../pages/details-page'; +import { ModalPage } from '../../../../pages/modal-page'; +import { SecretsPage } from '../../../../pages/secrets-page'; + +function encode(username: string, password: string): string { + return Base64.encode(`${username}:${password}`); +} + +test.describe('Image pull secrets', () => { + let namespace: string; + + test.beforeAll(async ({ k8sClient }) => { + namespace = `test-image-pull-${Date.now()}`; + await k8sClient.createNamespace(namespace); + await k8sClient.waitForNamespaceReady(namespace); + }); + + test.afterAll(async ({ k8sClient }) => { + await k8sClient.deleteNamespace(namespace); + }); + + test('creates, edits, and deletes an image registry credentials pull secret', async ({ + page, + k8sClient, + }) => { + const secretName = `img-creds-${Date.now()}`; + const secretsPage = new SecretsPage(page); + const detailsPage = new DetailsPage(page); + const modalPage = new ModalPage(page); + + const address = 'https://index.openshift.io/v'; + const username = 'username'; + const password = 'password'; + const mail = 'test@secret.com'; + + const credentialsToCheck = { + '.dockerconfigjson': { + auths: { + [`${address}0`]: { + username: `${username}0`, + password: `${password}0`, + auth: encode(`${username}0`, `${password}0`), + email: `${mail}0`, + }, + [`${address}1`]: { + username: `${username}1`, + password: `${password}1`, + auth: encode(`${username}1`, `${password}1`), + email: `${mail}1`, + }, + }, + }, + }; + + const addressUpdated = 'https://index.openshift.io/updated/v1'; + const usernameUpdated = `${username}Updated`; + const passwordUpdated = `${password}Updated`; + const mailUpdated = 'testUpdated@secret.com'; + + const updatedCredentialsToCheck = { + '.dockerconfigjson': { + auths: { + [addressUpdated]: { + username: usernameUpdated, + password: passwordUpdated, + auth: encode(usernameUpdated, passwordUpdated), + email: mailUpdated, + }, + }, + }, + }; + + await test.step('Create secret with two credential entries', async () => { + await secretsPage.navigateToCreateImagePullSecret(namespace); + await expect(secretsPage.getPageHeading()).toContainText('Create image pull secret'); + await secretsPage.enterSecretName(secretName); + await secretsPage.clickAddCredentials(); + await secretsPage.fillCredentialEntry(0, `${address}0`, `${username}0`, `${password}0`, `${mail}0`); + await secretsPage.fillCredentialEntry(1, `${address}1`, `${username}1`, `${password}1`, `${mail}1`); + await secretsPage.save(); + }); + + await test.step('Verify secret data', async () => { + await secretsPage.navigateToSecretDetails(namespace, secretName); + await detailsPage.waitForPageLoad(); + await secretsPage.checkSecretData(credentialsToCheck, true); + }); + + await test.step('Edit secret with whitespace in input values', async () => { + await detailsPage.clickActionsMenuAction('Edit Secret'); + await expect(secretsPage.getPageHeading()).toContainText('Edit image pull secret'); + await expect(secretsPage.getCredentialForm()).toHaveCount(2); + await secretsPage.clickRemoveFirstEntry(); + await secretsPage.getAddressInput().fill(` ${addressUpdated} `); + await secretsPage.getUsernameInput().fill(` ${usernameUpdated} `); + await secretsPage.getPasswordInput().fill(` ${passwordUpdated} `); + await secretsPage.getEmailInput().fill(` ${mailUpdated} `); + await secretsPage.save(); + }); + + await test.step('Verify whitespace trimmed after edit', async () => { + await secretsPage.navigateToSecretDetails(namespace, secretName); + await detailsPage.waitForPageLoad(); + await secretsPage.checkSecretData(updatedCredentialsToCheck, true); + }); + + await test.step('Delete secret', async () => { + await detailsPage.clickActionsMenuAction('Delete Secret'); + await modalPage.waitForOpen(); + await modalPage.submit(); + await modalPage.waitForClosed(); + }); + + await k8sClient.deleteSecret(secretName, namespace); + }); + + test('creates and deletes an upload configuration file image pull secret', async ({ + page, + k8sClient, + }) => { + const secretName = `img-config-${Date.now()}`; + const secretsPage = new SecretsPage(page); + const detailsPage = new DetailsPage(page); + const modalPage = new ModalPage(page); + + const configFile = { + auths: { + 'https://index.openshift.io/v1': { + username: 'username', + password: 'password', + auth: encode('username', 'password'), + email: 'test@secret.com', + }, + }, + }; + + await test.step('Create secret with uploaded config file', async () => { + await secretsPage.navigateToCreateImagePullSecret(namespace); + await expect(secretsPage.getPageHeading()).toContainText('Create image pull secret'); + await secretsPage.enterSecretName(secretName); + await secretsPage.selectAuthType('config-file'); + await secretsPage.getFileInputTextarea().fill(JSON.stringify(configFile)); + await expect(page.getByTestId('save-changes')).toBeEnabled({ timeout: 30_000 }); + await secretsPage.save(); + }); + + await test.step('Verify secret data', async () => { + await secretsPage.navigateToSecretDetails(namespace, secretName); + await detailsPage.waitForPageLoad(); + await secretsPage.checkSecretData({ '.dockerconfigjson': configFile }, true); + }); + + await test.step('Delete secret', async () => { + await detailsPage.clickActionsMenuAction('Delete Secret'); + await modalPage.waitForOpen(); + await modalPage.submit(); + await modalPage.waitForClosed(); + }); + + await k8sClient.deleteSecret(secretName, namespace); + }); + + test('passwords entered on the console are obfuscated', async ({ page }) => { + const secretsPage = new SecretsPage(page); + + await secretsPage.navigateToCreateImagePullSecret(namespace); + await expect(secretsPage.getPasswordInput()).toHaveAttribute('type', 'password'); + + await secretsPage.navigateToCreateSourceSecret(namespace); + await expect(secretsPage.getSecretPasswordInput()).toHaveAttribute('type', 'password'); + }); +}); diff --git a/frontend/e2e/tests/console/crud/secrets/key-value.spec.ts b/frontend/e2e/tests/console/crud/secrets/key-value.spec.ts new file mode 100644 index 00000000000..48afa820abb --- /dev/null +++ b/frontend/e2e/tests/console/crud/secrets/key-value.spec.ts @@ -0,0 +1,229 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { test, expect } from '../../../../fixtures'; +import { warmupSPA } from '../../../../pages/base-page'; +import { DetailsPage } from '../../../../pages/details-page'; +import { SecretsPage } from '../../../../pages/secrets-page'; + +const BINARY_FILENAME = 'binarysecret.bin'; +const ASCII_FILENAME = 'asciisecret.txt'; +const UNICODE_FILENAME = 'unicodesecret.utf8'; +const SECRET_KEY = 'secretkey'; +const MODIFIED_SECRET_KEY = 'modifiedsecretkey'; + +function fixturePath(filename: string): string { + return path.resolve(import.meta.dirname, '../../../../mocks/secrets', filename); +} + +function readFixtureBase64(filename: string): string { + return fs.readFileSync(fixturePath(filename)).toString('base64'); +} + +function readFixtureUtf8(filename: string): string { + return fs.readFileSync(fixturePath(filename), 'utf-8'); +} + +test.describe('Create key/value secrets', () => { + let namespace: string; + const tlsSecretName = `kv-tls-secret-${Date.now()}`; + + test.beforeAll(async ({ k8sClient }) => { + namespace = `test-kv-secrets-${Date.now()}`; + await k8sClient.createNamespace(namespace); + await k8sClient.waitForNamespaceReady(namespace); + + await k8sClient.createSecret(tlsSecretName, namespace, { + 'tls.crt': 'QUFBCQ==', + 'tls.key': 'QkJCCg==', + }); + }); + + test.afterAll(async ({ k8sClient }) => { + await k8sClient.deleteNamespace(namespace); + }); + + test('creates and edits a key/value secret with a binary file', async ({ + page, + k8sClient, + }) => { + const secretName = `kv-binary-${Date.now()}`; + const secretsPage = new SecretsPage(page); + const detailsPage = new DetailsPage(page); + const expectedBase64 = readFixtureBase64(BINARY_FILENAME); + + await test.step('Create secret with binary file', async () => { + await secretsPage.navigateToCreateGenericSecret(namespace); + await expect(secretsPage.getPageHeading()).toContainText('Create key/value secret'); + await secretsPage.enterSecretName(secretName); + await secretsPage.fillSecretKey(SECRET_KEY); + await secretsPage.uploadFile(fixturePath(BINARY_FILENAME)); + await expect(secretsPage.getFileInputTextarea()).not.toBeAttached(); + await expect(secretsPage.getBinaryAlert()).toBeVisible(); + await secretsPage.save(); + }); + + await test.step('Verify secret details page', async () => { + await detailsPage.waitForPageLoad(); + await expect(detailsPage.title).toContainText(secretName); + }); + + await test.step('Verify binary data integrity via API', async () => { + const secret = (await k8sClient.coreV1Api.readNamespacedSecret({ + name: secretName, + namespace, + })) as { data?: Record }; + expect(secret.data?.[SECRET_KEY]).toBe(expectedBase64); + }); + + await test.step('Edit secret key', async () => { + await detailsPage.clickActionsMenuAction('Edit Secret'); + await expect(secretsPage.getPageHeading()).toContainText('Edit key/value secret'); + await secretsPage.fillSecretKey(MODIFIED_SECRET_KEY); + await expect(secretsPage.getBinaryAlert()).toBeVisible(); + await secretsPage.save(); + }); + + await test.step('Verify edited secret', async () => { + await detailsPage.waitForPageLoad(); + await expect(detailsPage.title).toContainText(secretName); + const secret = (await k8sClient.coreV1Api.readNamespacedSecret({ + name: secretName, + namespace, + })) as { data?: Record }; + expect(secret.data?.[MODIFIED_SECRET_KEY]).toBe(expectedBase64); + }); + + await k8sClient.deleteSecret(secretName, namespace); + }); + + test('creates a key/value secret with an ascii file', async ({ page, k8sClient }) => { + const secretName = `kv-ascii-${Date.now()}`; + const secretsPage = new SecretsPage(page); + const detailsPage = new DetailsPage(page); + const asciiContent = readFixtureUtf8(ASCII_FILENAME); + + await test.step('Create secret with ascii file', async () => { + await secretsPage.navigateToCreateGenericSecret(namespace); + await secretsPage.enterSecretName(secretName); + await secretsPage.fillSecretKey(SECRET_KEY); + await secretsPage.uploadFile(fixturePath(ASCII_FILENAME)); + await expect(secretsPage.getFileInputTextarea()).toContainText(asciiContent); + await expect(secretsPage.getBinaryAlert()).not.toBeAttached(); + await secretsPage.save(); + }); + + await test.step('Verify secret details page', async () => { + await detailsPage.waitForPageLoad(); + await expect(detailsPage.title).toContainText(secretName); + }); + + await test.step('Verify ascii data integrity via API', async () => { + const secret = (await k8sClient.coreV1Api.readNamespacedSecret({ + name: secretName, + namespace, + })) as { data?: Record }; + const decoded = Buffer.from(secret.data?.[SECRET_KEY] ?? '', 'base64').toString('utf-8'); + expect(decoded).toBe(asciiContent); + }); + + await k8sClient.deleteSecret(secretName, namespace); + }); + + test('creates a key/value secret with a unicode file', async ({ page, k8sClient }) => { + const secretName = `kv-unicode-${Date.now()}`; + const secretsPage = new SecretsPage(page); + const detailsPage = new DetailsPage(page); + const unicodeContent = readFixtureUtf8(UNICODE_FILENAME); + + await test.step('Create secret with unicode file', async () => { + await secretsPage.navigateToCreateGenericSecret(namespace); + await secretsPage.enterSecretName(secretName); + await secretsPage.fillSecretKey(SECRET_KEY); + await secretsPage.uploadFile(fixturePath(UNICODE_FILENAME)); + await expect(secretsPage.getFileInputTextarea()).toContainText(unicodeContent); + await expect(secretsPage.getBinaryAlert()).not.toBeAttached(); + await secretsPage.save(); + }); + + await test.step('Verify secret details page', async () => { + await detailsPage.waitForPageLoad(); + await expect(detailsPage.title).toContainText(secretName); + }); + + await test.step('Verify unicode data integrity via API', async () => { + const secret = (await k8sClient.coreV1Api.readNamespacedSecret({ + name: secretName, + namespace, + })) as { data?: Record }; + const decoded = Buffer.from(secret.data?.[SECRET_KEY] ?? '', 'base64').toString('utf-8'); + expect(decoded).toBe(unicodeContent); + }); + + await k8sClient.deleteSecret(secretName, namespace); + }); + + test('edits a tls secret to add a key/value pair', async ({ page }) => { + const secretsPage = new SecretsPage(page); + + await secretsPage.navigateToEditSecret(namespace, tlsSecretName); + await secretsPage.addKeyValue('keyfortest', 'valuefortest'); + await secretsPage.save(); + + const detailsPage = new DetailsPage(page); + await detailsPage.waitForPageLoad(); + await expect(detailsPage.title).toContainText(tlsSecretName); + await secretsPage.clickRevealValues(); + await expect(secretsPage.getSecretDataTerm().first()).toHaveText('keyfortest'); + await expect(secretsPage.getCopyToClipboard().first()).toContainText('valuefortest'); + }); + + test('editing text field does not corrupt binary data (OCPBUGS-70273)', async ({ + page, + k8sClient, + }) => { + const secretName = `kv-mixed-${Date.now()}`; + const textKey = 'textfield'; + const textValue = 'original-password'; + const updatedTextValue = 'updated-password'; + const binaryKey = 'binaryfield'; + const secretsPage = new SecretsPage(page); + const detailsPage = new DetailsPage(page); + + const binaryBase64 = readFixtureBase64(BINARY_FILENAME); + const textBase64 = Buffer.from(textValue).toString('base64'); + + await test.step('Create mixed secret via API', async () => { + await k8sClient.createSecret(secretName, namespace, { + [textKey]: textBase64, + [binaryKey]: binaryBase64, + }); + }); + + await test.step('Edit only the text field via UI', async () => { + await warmupSPA(page); + await secretsPage.navigateToSecretDetails(namespace, secretName); + await detailsPage.waitForPageLoad(); + await detailsPage.clickActionsMenuAction('Edit Secret'); + await secretsPage.getFileInputTextarea().first().fill(updatedTextValue); + await expect(secretsPage.getBinaryAlert()).toBeVisible(); + await secretsPage.save(); + }); + + await test.step('Verify text field was updated', async () => { + await detailsPage.waitForPageLoad(); + await secretsPage.clickRevealValues(); + await expect(secretsPage.getCopyToClipboard()).toContainText([updatedTextValue]); + }); + + await test.step('Verify binary data was NOT corrupted', async () => { + const secret = (await k8sClient.coreV1Api.readNamespacedSecret({ + name: secretName, + namespace, + })) as { data?: Record }; + expect(secret.data?.[binaryKey]).toBe(binaryBase64); + }); + + await k8sClient.deleteSecret(secretName, namespace); + }); +}); diff --git a/frontend/e2e/tests/console/crud/secrets/source.spec.ts b/frontend/e2e/tests/console/crud/secrets/source.spec.ts new file mode 100644 index 00000000000..1a8e4cfe9d0 --- /dev/null +++ b/frontend/e2e/tests/console/crud/secrets/source.spec.ts @@ -0,0 +1,121 @@ +import { test, expect } from '../../../../fixtures'; +import { DetailsPage } from '../../../../pages/details-page'; +import { ModalPage } from '../../../../pages/modal-page'; +import { SecretsPage } from '../../../../pages/secrets-page'; + +test.describe('Source secrets', () => { + let namespace: string; + + test.beforeAll(async ({ k8sClient }) => { + namespace = `test-source-secrets-${Date.now()}`; + await k8sClient.createNamespace(namespace); + await k8sClient.waitForNamespaceReady(namespace); + }); + + test.afterAll(async ({ k8sClient }) => { + await k8sClient.deleteNamespace(namespace); + }); + + test('creates, edits, and deletes a basic source secret', async ({ page, k8sClient }) => { + const secretName = `basic-src-${Date.now()}`; + const secretsPage = new SecretsPage(page); + const detailsPage = new DetailsPage(page); + const modalPage = new ModalPage(page); + + const username = 'username'; + const password = 'password'; + const usernameUpdated = 'usernameUpdated'; + const passwordUpdated = 'passwordUpdated'; + + await test.step('Create basic source secret', async () => { + await secretsPage.navigateToCreateSourceSecret(namespace); + await expect(secretsPage.getPageHeading()).toContainText('Create source secret'); + await secretsPage.enterSecretName(secretName); + await page.getByTestId('secret-username').fill(username); + await page.getByTestId('secret-password').fill(password); + await secretsPage.save(); + }); + + await test.step('Verify secret data', async () => { + await detailsPage.waitForPageLoad(); + await expect(detailsPage.title).toContainText(secretName); + await secretsPage.checkSecretData({ password, username }); + }); + + await test.step('Edit secret', async () => { + await detailsPage.clickActionsMenuAction('Edit Secret'); + await expect(secretsPage.getPageHeading()).toContainText('Edit source secret'); + await expect(page.getByTestId('secret-username')).toHaveValue(username); + await expect(page.getByTestId('secret-password')).toHaveValue(password); + await page.getByTestId('secret-username').fill(usernameUpdated); + await page.getByTestId('secret-password').fill(passwordUpdated); + await secretsPage.save(); + }); + + await test.step('Verify edited secret data', async () => { + await detailsPage.waitForPageLoad(); + await expect(detailsPage.title).toContainText(secretName); + await secretsPage.checkSecretData({ + password: passwordUpdated, + username: usernameUpdated, + }); + }); + + await test.step('Delete secret', async () => { + await detailsPage.clickActionsMenuAction('Delete Secret'); + await modalPage.waitForOpen(); + await modalPage.submit(); + await modalPage.waitForClosed(); + }); + + await k8sClient.deleteSecret(secretName, namespace); + }); + + test('creates, edits, and deletes a SSH source secret', async ({ page, k8sClient }) => { + const secretName = `ssh-src-${Date.now()}`; + const secretsPage = new SecretsPage(page); + const detailsPage = new DetailsPage(page); + const modalPage = new ModalPage(page); + + const sshKey = 'sshKey'; + const sshKeyUpdated = 'sshKeyUpdated'; + + await test.step('Create SSH source secret', async () => { + await secretsPage.navigateToCreateSourceSecret(namespace); + await expect(secretsPage.getPageHeading()).toContainText('Create source secret'); + await secretsPage.enterSecretName(secretName); + await secretsPage.selectAuthType('ssh-auth'); + await secretsPage.getFileInputTextarea().fill(sshKey); + await secretsPage.save(); + }); + + await test.step('Verify secret data', async () => { + await detailsPage.waitForPageLoad(); + await expect(detailsPage.title).toContainText(secretName); + await secretsPage.checkSecretData({ 'ssh-privatekey': `${sshKey}\n` }); + }); + + await test.step('Edit secret', async () => { + await detailsPage.clickActionsMenuAction('Edit Secret'); + await expect(secretsPage.getPageHeading()).toContainText('Edit source secret'); + await expect(secretsPage.getFileInputTextarea()).toHaveValue(`${sshKey}\n`); + await secretsPage.getFileInputTextarea().fill(sshKeyUpdated); + await secretsPage.save(); + }); + + await test.step('Verify edited secret data', async () => { + await detailsPage.waitForPageLoad(); + await expect(detailsPage.title).toContainText(secretName); + await secretsPage.checkSecretData({ 'ssh-privatekey': `${sshKeyUpdated}\n` }); + }); + + await test.step('Delete secret', async () => { + await detailsPage.clickActionsMenuAction('Delete Secret'); + await modalPage.waitForOpen(); + await modalPage.submit(); + await modalPage.waitForClosed(); + }); + + await k8sClient.deleteSecret(secretName, namespace); + }); +}); diff --git a/frontend/e2e/tests/console/crud/secrets/webhook.spec.ts b/frontend/e2e/tests/console/crud/secrets/webhook.spec.ts new file mode 100644 index 00000000000..23e90afcc37 --- /dev/null +++ b/frontend/e2e/tests/console/crud/secrets/webhook.spec.ts @@ -0,0 +1,66 @@ +import { test, expect } from '../../../../fixtures'; +import { DetailsPage } from '../../../../pages/details-page'; +import { ModalPage } from '../../../../pages/modal-page'; +import { SecretsPage } from '../../../../pages/secrets-page'; + +test.describe('Webhook secret', () => { + let namespace: string; + + test.beforeAll(async ({ k8sClient }) => { + namespace = `test-webhook-secret-${Date.now()}`; + await k8sClient.createNamespace(namespace); + await k8sClient.waitForNamespaceReady(namespace); + }); + + test.afterAll(async ({ k8sClient }) => { + await k8sClient.deleteNamespace(namespace); + }); + + test('creates, edits, and deletes a webhook secret', async ({ page, k8sClient }) => { + const secretName = `webhook-${Date.now()}`; + const webhookSecretKey = 'webhookValue'; + const secretsPage = new SecretsPage(page); + const detailsPage = new DetailsPage(page); + const modalPage = new ModalPage(page); + + await test.step('Create webhook secret', async () => { + await secretsPage.navigateToCreateWebhookSecret(namespace); + await expect(secretsPage.getPageHeading()).toContainText('Create webhook secret'); + await secretsPage.enterSecretName(secretName); + await secretsPage.fillSecretKey(webhookSecretKey); + await secretsPage.save(); + }); + + await test.step('Verify secret data', async () => { + await detailsPage.waitForPageLoad(); + await expect(detailsPage.title).toContainText(secretName); + await secretsPage.checkSecretData({ WebHookSecretKey: webhookSecretKey }); + }); + + await test.step('Edit secret with generated value', async () => { + await detailsPage.clickActionsMenuAction('Edit Secret'); + await expect(secretsPage.getPageHeading()).toContainText('Edit webhook secret'); + await expect(page.getByTestId('webhook-generate-button')).toBeVisible(); + await page.getByTestId('webhook-generate-button').click(); + await secretsPage.save(); + }); + + await test.step('Verify generated value is different', async () => { + await detailsPage.waitForPageLoad(); + await expect(detailsPage.title).toContainText(secretName); + await secretsPage.clickRevealValues(); + const generatedValue = secretsPage.getCopyToClipboard().first(); + await expect(generatedValue).toHaveText(/\S+/); + await expect(generatedValue).not.toHaveText(webhookSecretKey); + }); + + await test.step('Delete secret', async () => { + await detailsPage.clickActionsMenuAction('Delete Secret'); + await modalPage.waitForOpen(); + await modalPage.submit(); + await modalPage.waitForClosed(); + }); + + await k8sClient.deleteSecret(secretName, namespace); + }); +}); diff --git a/frontend/packages/integration-tests/tests/crud/secrets/add-to-workload.cy.ts b/frontend/packages/integration-tests/tests/crud/secrets/add-to-workload.cy.ts deleted file mode 100644 index 61bf5825f09..00000000000 --- a/frontend/packages/integration-tests/tests/crud/secrets/add-to-workload.cy.ts +++ /dev/null @@ -1,116 +0,0 @@ -import * as _ from 'lodash'; -import type { DeploymentKind } from '@console/internal/module/k8s'; -import { checkErrors, testName } from '../../../support'; -import { modal } from '../../../views/modal'; -import { secrets } from '../../../views/secret'; - -const secretName = 'test-secret'; -const resourceName = 'test-deploy'; -const resourceKind = 'deployment'; -const envPrefix = 'env-'; -const mountPath = '/tmp/testdata'; -const deployment: DeploymentKind = { - apiVersion: 'apps/v1', - kind: 'Deployment', - metadata: { - name: resourceName, - namespace: testName, - }, - spec: { - selector: { - matchLabels: { - test: 'add-secret-to-workload', - }, - }, - template: { - metadata: { - labels: { - test: 'add-secret-to-workload', - }, - }, - spec: { - containers: [ - { - name: 'httpd', - image: 'image-registry.openshift-image-registry.svc:5000/openshift/httpd:latest', - }, - ], - }, - }, - }, -}; - -describe('Add Secret to Workloads', () => { - before(() => { - cy.login(); - cy.createProjectWithCLI(testName); - cy.exec(`echo '${JSON.stringify(deployment)}' | oc create -n ${testName} -f -`); - cy.exec( - `oc create secret generic ${secretName} --from-literal=key1=supersecret -n ${testName}`, - ); - }); - - beforeEach(() => { - cy.visit(`/k8s/ns/${testName}/secrets/${secretName}`); - }); - - afterEach(() => { - checkErrors(); - }); - - after(() => { - cy.deleteProjectWithCLI(testName); - }); - - it(`Adds Secret to Deployment as Environment Variables`, () => { - cy.log('Add Secret'); - secrets.addSecretToWorkload(resourceName); - cy.byTestID('Environment variables-radio-input').click(); - cy.byTestID('add-secret-to-workload-prefix').type(envPrefix); - modal.submitShouldBeEnabled(); - modal.submit(); - - cy.log('Verify Secret'); - secrets.getResourceJSON(resourceName, testName, resourceKind).then((resourceJSON) => { - const resource = JSON.parse(resourceJSON.stdout); - const name = _.get( - resource, - 'spec.template.spec.containers[0].envFrom[0].secretRef.name', - undefined, - ); - expect(name).to.equal(secretName); - const prefix = _.get( - resource, - 'spec.template.spec.containers[0].envFrom[0].prefix', - undefined, - ); - expect(prefix).to.equal(envPrefix); - }); - }); - - it(`Adds Secret to Deployment as Volume`, () => { - cy.log('Add Secret'); - secrets.addSecretToWorkload(resourceName); - cy.byTestID('Volume-radio-input').click(); - cy.byTestID('add-secret-to-workload-mountpath').type(mountPath); - modal.submitShouldBeEnabled(); - modal.submit(); - - cy.log('Verify Secret'); - secrets.getResourceJSON(resourceName, testName, resourceKind).then((resourceJSON) => { - const resource = JSON.parse(resourceJSON.stdout); - const name = _.get( - resource, - 'spec.template.spec.containers[0].volumeMounts[0].name', - undefined, - ); - expect(name).to.equal(secretName); - const mp = _.get( - resource, - 'spec.template.spec.containers[0].volumeMounts[0].mountPath', - undefined, - ); - expect(mp).to.equal(mountPath); - }); - }); -}); diff --git a/frontend/packages/integration-tests/tests/crud/secrets/image-pull.cy.ts b/frontend/packages/integration-tests/tests/crud/secrets/image-pull.cy.ts deleted file mode 100644 index 018ba794fa6..00000000000 --- a/frontend/packages/integration-tests/tests/crud/secrets/image-pull.cy.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { checkErrors, testName } from '../../../support'; -import { detailsPage } from '../../../views/details-page'; -import { secrets } from '../../../views/secret'; - -const heading = 'Create image pull secret'; - -describe('Image pull secrets', () => { - before(() => { - cy.login(); - cy.createProjectWithCLI(testName); - }); - - beforeEach(function () { - // Skip beforeEach for the obfuscated passwords test - if (this.currentTest?.title === 'Passwords entered on the console are obfuscated') { - return; - } - // ensure the test project is selected to avoid flakes - cy.visit(`/k8s/cluster/projects/${testName}`); - cy.visit(`/k8s/ns/${testName}/secrets/`); - secrets.clickCreateSecretDropdownButton('image'); - }); - - afterEach(() => { - const credentialsImageSecretName = `registry-credentials-image-secret-${testName}`; - const uploadConfigFileImageSecretName = `upload-configuration-file-image-secret-${testName}`; - cy.exec( - `oc delete secret -n ${testName} ${credentialsImageSecretName} ${uploadConfigFileImageSecretName}`, - { - failOnNonZeroExit: false, - }, - ); - checkErrors(); - }); - - after(() => { - cy.exec(`oc delete project ${testName} --wait=false`); - }); - - it(`Creates, edits, and deletes an image registry credentials pull secret`, () => { - const credentialsImageSecretName = `registry-credentials-image-secret-${testName}`; - const address = 'https://index.openshift.io/v'; - const addressUpdated = 'https://index.openshift.io/updated/v1'; - const username = 'username'; - const password = 'password'; - const username0 = `${username}0`; - const password0 = `${password}0`; - const username1 = `${username}1`; - const password1 = `${password}1`; - const usernameUpdated = `${username}Updated`; - const passwordUpdated = `${password}Updated`; - const mail = 'test@secret.com'; - const mail0 = `${mail}0`; - const mail1 = `${mail}1`; - const mailUpdated = 'testUpdated@secret.com'; - - const credentialsToCheck = { - '.dockerconfigjson': { - auths: { - 'https://index.openshift.io/v0': { - username: username0, - password: password0, - auth: secrets.encode(username0, password0), - email: mail0, - }, - 'https://index.openshift.io/v1': { - username: username1, - password: password1, - auth: secrets.encode(username1, password1), - email: mail1, - }, - }, - }, - }; - const updatedCredentialsToCheck = { - '.dockerconfigjson': { - auths: { - 'https://index.openshift.io/updated/v1': { - username: usernameUpdated, - password: passwordUpdated, - auth: secrets.encode(usernameUpdated, passwordUpdated), - email: mailUpdated, - }, - }, - }, - }; - - cy.log('Create secret'); - cy.byTestID('page-heading').contains(heading); - secrets.enterSecretName(credentialsImageSecretName); - secrets.clickAddCredentialsButton(); - cy.get('[data-test-id="create-image-secret-form"]').each(($el, index) => { - cy.wrap($el).find('[data-test="image-secret-address"]').type(`${address}${index}`); - cy.wrap($el).find('[data-test="image-secret-username"]').type(`${username}${index}`); - cy.wrap($el).find('[data-test="image-secret-password"]').type(`${password}${index}`); - cy.wrap($el).find('[data-test="image-secret-email"]').type(`${mail}${index}`); - }); - secrets.save(); - cy.byTestID('loading-indicator').should('not.exist'); - - // Navigate to secret details page (save may go to list page) - cy.url({ timeout: 30000 }).then((url) => { - if (!url.includes(`/secrets/${credentialsImageSecretName}`)) { - cy.visit(`/k8s/ns/${testName}/secrets/${credentialsImageSecretName}`); - } - }); - secrets.detailsPageIsLoaded(credentialsImageSecretName); - - cy.log('Verify secret'); - secrets.checkSecret(credentialsToCheck, true); - - cy.log('Edit secret with whitespace in input values'); - detailsPage.clickPageActionFromDropdown('Edit Secret'); - // Wait for form to load - cy.byTestID('page-heading').contains('Edit image pull secret'); - cy.get('[data-test-id="create-image-secret-form"]').should('have.length', 2); - secrets.clickRemoveEntryButton(); - cy.byTestID('image-secret-address').clear(); - cy.byTestID('image-secret-address').type(` ${addressUpdated} `); - cy.byTestID('image-secret-username').clear(); - cy.byTestID('image-secret-username').type(` ${usernameUpdated} `); - cy.byTestID('image-secret-password').clear(); - cy.byTestID('image-secret-password').type(` ${passwordUpdated} `); - cy.byTestID('image-secret-email').clear(); - cy.byTestID('image-secret-email').type(` ${mailUpdated} `); - secrets.save(); - cy.byTestID('loading-indicator').should('not.exist'); - - // Navigate to secret details page (save may go to list page) - cy.url({ timeout: 30000 }).then((url) => { - if (!url.includes(`/secrets/${credentialsImageSecretName}`)) { - cy.visit(`/k8s/ns/${testName}/secrets/${credentialsImageSecretName}`); - } - }); - - cy.log('Verify edit, whitespace in input values are removed'); - secrets.detailsPageIsLoaded(credentialsImageSecretName); - secrets.checkSecret(updatedCredentialsToCheck, true); - - cy.log('Delete secret'); - secrets.deleteSecret(credentialsImageSecretName); - }); - - it(`Creates and deletes an upload configuration file image pull secret`, () => { - const uploadConfigFileImageSecretName = `upload-configuration-file-image-secret-${testName}`; - const username = 'username'; - const password = 'password'; - const configFile = { - auths: { - 'https://index.openshift.io/v1': { - username, - password, - auth: secrets.encode(username, password), - email: 'test@secret.com', - }, - }, - }; - - cy.log('Create secret'); - cy.byTestID('page-heading').contains(heading); - secrets.enterSecretName(uploadConfigFileImageSecretName); - cy.byTestID('console-select-auth-type-menu-toggle').click(); - cy.byTestDropDownMenu('config-file').click(); - - // Type the JSON config to properly trigger React state updates and Yup validation - const configJson = JSON.stringify(configFile); - cy.byLegacyTestID('file-input-textarea') - .clear() - .type(configJson, { delay: 0, parseSpecialCharSequences: false }); - - // Wait for validation to complete and save button to be enabled - cy.byTestID('save-changes', { timeout: 30000 }).should('be.visible').and('be.enabled'); - - secrets.save(); - cy.byTestID('loading-indicator').should('not.exist'); - - // Navigate to secret details page (save may go to list page) - cy.url({ timeout: 30000 }).then((url) => { - if (!url.includes(`/secrets/${uploadConfigFileImageSecretName}`)) { - // If we're on list page, click on the secret to go to details - cy.visit(`/k8s/ns/${testName}/secrets/${uploadConfigFileImageSecretName}`); - } - }); - secrets.detailsPageIsLoaded(uploadConfigFileImageSecretName); - - cy.log('Verify secret'); - secrets.checkSecret( - { - '.dockerconfigjson': configFile, - }, - true, - ); - - cy.log('Delete secret'); - secrets.deleteSecret(uploadConfigFileImageSecretName); - }); - it(`Passwords entered on the console are obfuscated`, () => { - // Navigate to secrets page and open image secret form - cy.visit(`/k8s/ns/${testName}/secrets/`); - secrets.clickCreateSecretDropdownButton('image'); - cy.get('input[data-test="image-secret-password"]').should('have.attr', 'type', 'password'); - cy.get('button[id="cancel"]').click(); - - // Open source secret form - secrets.clickCreateSecretDropdownButton('source'); - cy.get('input[data-test="secret-password"]').should('have.attr', 'type', 'password'); - - // Clean up - navigate back to secrets list to close any open forms - cy.visit(`/k8s/ns/${testName}/secrets/`); - }); -}); diff --git a/frontend/packages/integration-tests/tests/crud/secrets/key-value.cy.ts b/frontend/packages/integration-tests/tests/crud/secrets/key-value.cy.ts deleted file mode 100644 index 814f0f45851..00000000000 --- a/frontend/packages/integration-tests/tests/crud/secrets/key-value.cy.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { checkErrors, testName } from '../../../support'; -import { detailsPage } from '../../../views/details-page'; -import { listPage } from '../../../views/list-page'; -import { nav } from '../../../views/nav'; -import { secrets } from '../../../views/secret'; - -const populateSecretForm = (name: string, key: string, fileName: string) => { - cy.get('[data-test="page-heading"] h1').contains('Create key/value secret'); - cy.byTestID('secret-name').should('exist'); - cy.byLegacyTestID('file-input-textarea').should('exist'); - secrets.enterSecretName(name); - cy.byTestID('secret-key').type(key); - cy.get('.co-file-input').selectFile( - `${Cypress.config('fileServerFolder')}/fixtures/${fileName}`, - { - action: 'drag-drop', - force: true, - }, - ); -}; - -const modifySecretForm = (key: string) => { - detailsPage.clickPageActionFromDropdown('Edit Secret'); - cy.get('[data-test="page-heading"] h1').contains('Edit key/value secret'); - cy.byTestID('secret-key').clear().type(key); -}; - -describe('Create key/value secrets', () => { - const binarySecretName = `key-value-binary-secret-${testName}`; - const asciiSecretName = `key-value-ascii-secret-${testName}`; - const unicodeSecretName = `key-value-unicode-secret-${testName}`; - const tlsSecretName = `key-value-tls-secret-${testName}`; - const binaryFilename = 'binarysecret.bin'; - const asciiFilename = 'asciisecret.txt'; - const unicodeFilename = 'unicodesecret.utf8'; - const secretKey = `secretkey`; - const modifiedSecretKey = 'modifiedsecretkey'; - const tlsSecretYaml = ` -apiVersion: v1 -kind: Secret -metadata: - name: ${tlsSecretName} -type: kubernetes.io/tls -data: - tls.crt: QUFBCg== - tls.key: QkJCCg== -`; - - before(() => { - cy.login(); - cy.createProjectWithCLI(testName); - cy.exec(`echo '${tlsSecretYaml}' | oc create -f - -n ${testName}`); - }); - - beforeEach(() => { - // ensure the test project is selected to avoid flakes - cy.visit(`/k8s/cluster/projects/${testName}`); - nav.sidenav.clickNavLink(['Workloads', 'Secrets']); - listPage.titleShouldHaveText('Secrets'); - secrets.clickCreateSecretDropdownButton('generic'); - }); - - afterEach(() => { - cy.exec( - `oc delete secret -n ${testName} ${binarySecretName} ${asciiSecretName} ${unicodeSecretName}`, - { - failOnNonZeroExit: false, - }, - ); - checkErrors(); - }); - - after(() => { - cy.deleteProjectWithCLI(testName); - }); - - it(`Validate create and edit of a key/value secret whose value is a binary file`, () => { - populateSecretForm(binarySecretName, secretKey, binaryFilename); - cy.byLegacyTestID('file-input-textarea').should('not.exist'); - cy.byTestID('file-input-binary-alert').should('exist'); - secrets.save(); - cy.byTestID('loading-indicator').should('not.exist'); - detailsPage.isLoaded(); - detailsPage.titleShouldContain(binarySecretName); - cy.exec( - `oc get secret -n ${testName} ${binarySecretName} --template '{{.data.${secretKey}}}'`, - { - failOnNonZeroExit: false, - }, - ).then((value) => { - cy.fixture(binaryFilename, 'base64').then((binarySecret) => { - expect(binarySecret).toEqual(value.stdout); - }); - }); - modifySecretForm(modifiedSecretKey); - cy.byTestID('file-input-binary-alert').should('exist'); - secrets.save(); - cy.byTestID('loading-indicator').should('not.exist'); - detailsPage.isLoaded(); - detailsPage.titleShouldContain(binarySecretName); - cy.exec( - `oc get secret -n ${testName} ${binarySecretName} --template '{{.data.${modifiedSecretKey}}}'`, - { - failOnNonZeroExit: false, - }, - ).then((value) => { - cy.fixture(binaryFilename, 'base64').then((binarySecret) => { - expect(binarySecret).toEqual(value.stdout); - }); - }); - }); - - it(`Validate a key/value secret whose value is an ascii file`, () => { - populateSecretForm(asciiSecretName, secretKey, asciiFilename); - cy.fixture(asciiFilename, 'ascii').then((asciiSecret) => { - cy.byLegacyTestID('file-input-textarea').should('contain.text', asciiSecret); - cy.byTestID('file-input-binary-alert').should('not.exist'); - secrets.save(); - cy.byTestID('loading-indicator').should('not.exist'); - detailsPage.isLoaded(); - detailsPage.titleShouldContain(asciiSecretName); - cy.exec( - `oc get secret -n ${testName} ${asciiSecretName} --template '{{.data.${secretKey}}}' | base64 -d`, - { - failOnNonZeroExit: false, - }, - ).then((value) => { - expect(asciiSecret).toEqual(value.stdout); - }); - }); - }); - - it(`Validate a key/value secret whose value is a unicode file`, () => { - populateSecretForm(unicodeSecretName, secretKey, unicodeFilename); - cy.fixture(unicodeFilename, 'utf8').then((unicodeSecret) => { - cy.byLegacyTestID('file-input-textarea').should('contain.text', unicodeSecret); - cy.byTestID('file-input-binary-alert').should('not.exist'); - secrets.save(); - cy.byTestID('loading-indicator').should('not.exist'); - detailsPage.isLoaded(); - detailsPage.titleShouldContain(unicodeSecretName); - cy.exec( - `oc get secret -n ${testName} ${unicodeSecretName} --template '{{.data.${secretKey}}}' | base64 -d`, - { - failOnNonZeroExit: false, - }, - ).then((value) => { - expect(unicodeSecret).toEqual(value.stdout); - }); - }); - }); - - it('Validate tls secret is editable', () => { - cy.visit(`/k8s/ns/${testName}/secrets/${tlsSecretName}/edit`); - secrets.addKeyValue('keyfortest', 'valuefortest'); - secrets.save(); - secrets.detailsPageIsLoaded(tlsSecretName); - secrets.checkKeyValueExist('keyfortest', 'valuefortest'); - }); - - it('Validate editing text field does not corrupt binary data (OCPBUGS-70273)', () => { - const mixedSecretName = `key-value-mixed-secret-${testName}`; - const textKey = 'textfield'; - const textValue = 'original-password'; - const updatedTextValue = 'updated-password'; - const binaryKey = 'binaryfield'; - - // Create a secret with both text and binary data using CLI - cy.exec( - `oc create secret generic ${mixedSecretName} -n ${testName} --from-literal=${textKey}=${textValue} --from-file=${binaryKey}=${Cypress.config( - 'fileServerFolder', - )}/fixtures/${binaryFilename}`, - ); - - // Capture the original binary data - cy.exec( - `oc get secret -n ${testName} ${mixedSecretName} --template '{{.data.${binaryKey}}}'`, - ).then((originalBinary) => { - // Edit the secret via the console - cy.visit(`/k8s/ns/${testName}/secrets/${mixedSecretName}`); - detailsPage.isLoaded(); - detailsPage.clickPageActionFromDropdown('Edit Secret'); - - // Modify only the text field - cy.byTestID('secret-key') - .should('have.length', 2) - .each(($el) => { - if ($el.val() === textKey) { - // Find the corresponding value textarea and update it - cy.byLegacyTestID('file-input-textarea').first().clear().type(updatedTextValue); - } - }); - - // Verify binary field shows the binary alert (indicates it's still treated as binary) - cy.byTestID('file-input-binary-alert').should('exist'); - - secrets.save(); - cy.byTestID('loading-indicator').should('not.exist'); - detailsPage.isLoaded(); - - // Verify the text field was updated - secrets.clickRevealValues(); - cy.byTestID('copy-to-clipboard').should('contain.text', updatedTextValue); - - // Verify the binary data was NOT corrupted - cy.exec( - `oc get secret -n ${testName} ${mixedSecretName} --template '{{.data.${binaryKey}}}'`, - ).then((updatedBinary) => { - expect(updatedBinary.stdout).to.equal(originalBinary.stdout); - }); - - // Cleanup - cy.exec(`oc delete secret -n ${testName} ${mixedSecretName}`, { - failOnNonZeroExit: false, - }); - }); - }); -}); diff --git a/frontend/packages/integration-tests/tests/crud/secrets/source.cy.ts b/frontend/packages/integration-tests/tests/crud/secrets/source.cy.ts deleted file mode 100644 index 293b649aece..00000000000 --- a/frontend/packages/integration-tests/tests/crud/secrets/source.cy.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { checkErrors, testName } from '../../../support'; -import { detailsPage } from '../../../views/details-page'; -import { secrets } from '../../../views/secret'; - -describe('Source secrets', () => { - const basicSourceSecretName = `basic-source-secret-${testName}`; - const basicSourceSecretUsername = 'username'; - const basicSourceSecretUsernameUpdated = 'usernameUpdated'; - const basicSourceSecretPassword = 'password'; - const basicSourceSecretPasswordUpdated = 'passwordUpdated'; - const sshSourceSecretName = `ssh-source-secret-${testName}`; - const sshSourceSecretSSHKey = 'sshKey'; - const sshSourceSecretSSHKeUpdated = 'sshKeyUpdated'; - - before(() => { - cy.login(); - cy.createProjectWithCLI(testName); - }); - - beforeEach(() => { - // ensure the test project is selected to avoid flakes - cy.visit(`/k8s/cluster/projects/${testName}`); - cy.visit(`/k8s/ns/${testName}/secrets/`); - secrets.clickCreateSecretDropdownButton('source'); - }); - - afterEach(() => { - cy.exec(`oc delete secret -n ${testName} ${basicSourceSecretName} ${sshSourceSecretName}`, { - failOnNonZeroExit: false, - }); - checkErrors(); - }); - - after(() => { - cy.deleteProjectWithCLI(testName); - }); - - it(`Creates, edits, and deletes a basic source secret`, () => { - cy.log('Create secret'); - cy.byTestID('page-heading').contains('Create source secret'); - secrets.enterSecretName(basicSourceSecretName); - cy.byTestID('secret-username').type(basicSourceSecretUsername); - cy.byTestID('secret-password').type(basicSourceSecretPassword); - secrets.save(); - cy.byTestID('loading-indicator').should('not.exist'); - secrets.detailsPageIsLoaded(basicSourceSecretName); - - cy.log('Verify secret'); - secrets.checkSecret({ - password: basicSourceSecretPassword, - username: basicSourceSecretUsername, - }); - - cy.log('Edit secret'); - detailsPage.clickPageActionFromDropdown('Edit Secret'); - // Wait for form to load and hydrate with current values - cy.byTestID('page-heading').contains('Edit source secret'); - cy.byTestID('secret-username').should('have.value', basicSourceSecretUsername); - cy.byTestID('secret-password').should('have.value', basicSourceSecretPassword); - cy.byTestID('secret-username').clear(); - cy.byTestID('secret-username').type(basicSourceSecretUsernameUpdated); - cy.byTestID('secret-password').clear(); - cy.byTestID('secret-password').type(basicSourceSecretPasswordUpdated); - secrets.save(); - cy.byTestID('loading-indicator').should('not.exist'); - - cy.log('Verify edit'); - secrets.detailsPageIsLoaded(basicSourceSecretName); - secrets.checkSecret({ - password: basicSourceSecretPasswordUpdated, - username: basicSourceSecretUsernameUpdated, - }); - - cy.log('Delete secret'); - secrets.deleteSecret(basicSourceSecretName); - }); - - it(`Creates, edits, and deletes a SSH source secret`, () => { - cy.log('Create secret'); - cy.byTestID('page-heading').contains('Create source secret'); - secrets.enterSecretName(sshSourceSecretName); - cy.byTestID('console-select-auth-type-menu-toggle').click(); - cy.byTestDropDownMenu('kubernetes.io/ssh-auth').click(); - cy.byLegacyTestID('file-input-textarea').type(sshSourceSecretSSHKey); - secrets.save(); - cy.byTestID('loading-indicator').should('not.exist'); - secrets.detailsPageIsLoaded(sshSourceSecretName); - - cy.log('Verify secret'); - secrets.checkSecret({ - 'ssh-privatekey': `${sshSourceSecretSSHKey}\n`, - }); - - cy.log('Edit secret'); - detailsPage.clickPageActionFromDropdown('Edit Secret'); - // Wait for form to load and hydrate with current values - cy.byTestID('page-heading').contains('Edit source secret'); - cy.byLegacyTestID('file-input-textarea').should('contain.value', sshSourceSecretSSHKey); - cy.byLegacyTestID('file-input-textarea').clear(); - cy.byLegacyTestID('file-input-textarea').type(sshSourceSecretSSHKeUpdated); - secrets.save(); - cy.byTestID('loading-indicator').should('not.exist'); - - cy.log('Verify edit'); - secrets.detailsPageIsLoaded(sshSourceSecretName); - secrets.checkSecret({ - 'ssh-privatekey': `${sshSourceSecretSSHKeUpdated}\n`, - }); - - cy.log('Delete secret'); - secrets.deleteSecret(sshSourceSecretName); - }); -}); diff --git a/frontend/packages/integration-tests/tests/crud/secrets/webhook.cy.ts b/frontend/packages/integration-tests/tests/crud/secrets/webhook.cy.ts deleted file mode 100644 index e611f3bca22..00000000000 --- a/frontend/packages/integration-tests/tests/crud/secrets/webhook.cy.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { checkErrors, testName } from '../../../support'; -import { detailsPage } from '../../../views/details-page'; -import { secrets } from '../../../views/secret'; - -describe('Webhook secret', () => { - const webhookSecretName = `webhook-secret-${testName}`; - const webhookSecretKey = 'webhookValue'; - - before(() => { - cy.login(); - cy.createProjectWithCLI(testName); - }); - - beforeEach(() => { - // ensure the test project is selected to avoid flakes - cy.visit(`/k8s/cluster/projects/${testName}`); - cy.visit(`/k8s/ns/${testName}/secrets/`); - secrets.clickCreateSecretDropdownButton('webhook'); - }); - - afterEach(() => { - cy.exec(`oc delete secret -n ${testName} ${webhookSecretName}`, { - failOnNonZeroExit: false, - }); - checkErrors(); - }); - - after(() => { - cy.deleteProjectWithCLI(testName); - }); - - it(`Create, edit, and delete a webhook secret`, () => { - cy.log('Create secret'); - cy.byTestID('page-heading').contains('Create webhook secret'); - secrets.enterSecretName(webhookSecretName); - cy.byTestID('secret-key').type(webhookSecretKey); - secrets.save(); - cy.byTestID('loading-indicator').should('not.exist'); - secrets.detailsPageIsLoaded(webhookSecretName); - - cy.log('Verify secret'); - secrets.checkSecret({ - WebHookSecretKey: webhookSecretKey, - }); - - cy.log('Edit secret'); - detailsPage.clickPageActionFromDropdown('Edit Secret'); - // Wait for form to load - cy.byTestID('page-heading').contains('Edit webhook secret'); - cy.byTestID('webhook-generate-button').should('be.visible'); - cy.byTestID('webhook-generate-button').click(); - secrets.save(); - cy.byTestID('loading-indicator').should('not.exist'); - - cy.log('Verify edit'); - secrets.detailsPageIsLoaded(webhookSecretName); - secrets.clickRevealValues(); - cy.get('.co-copy-to-clipboard__text .co-copy-to-clipboard__code') - .eq(0) - .invoke('text') - .should('not.equal', webhookSecretKey); - - cy.log('Delete secret'); - secrets.deleteSecret(webhookSecretName); - }); -}); diff --git a/frontend/packages/integration-tests/views/secret.ts b/frontend/packages/integration-tests/views/secret.ts deleted file mode 100644 index aa6da4db505..00000000000 --- a/frontend/packages/integration-tests/views/secret.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { Base64 } from 'js-base64'; -import { detailsPage } from './details-page'; -import { listPage } from './list-page'; -import { modal } from './modal'; - -export const secrets = { - addSecretToWorkload: (resourceName: string) => { - cy.byTestID('Add Secret to workload').click(); - modal.shouldBeOpened(); - modal.modalTitleShouldContain('Add secret to workload'); - cy.get('#co-add-secret-to-workload__workload').click(); - cy.byTestID('console-select-search-input').type(resourceName); - cy.byTestID('console-select-item').click(); - }, - addKeyValue: (key: string, value: string) => { - cy.byTestID('add-credentials-button').click(); - cy.byTestID('secret-key').last().clear().type(key); - cy.byLegacyTestID('file-input-textarea').last().clear().type(value); - }, - checkSecret: (keyValuesToCheck: object, jsonOutput: boolean = false) => { - secrets.clickRevealValues(); - const renderedKeyValues = {}; - cy.byTestID('secret-data') - .find('[data-test="secret-data-term"]') - .each(($el, index) => { - const key = $el.text(); - cy.get('[data-test="copy-to-clipboard"]') - .eq(index) - .invoke('text') - .then(($text) => { - renderedKeyValues[key] = jsonOutput ? JSON.parse($text) : $text; - }); - }) - .then(() => { - expect(renderedKeyValues).toEqual(keyValuesToCheck); - }); - }, - checkKeyValueExist: (key: string, value: string) => { - // Just for one new added key/value - secrets.clickRevealValues(); - cy.byTestID('secret-data-term').first().should('have.text', key); - cy.get('code').first().should('have.text', value); - }, - clickAddCredentialsButton: () => cy.byTestID('add-credentials-button').click(), - clickRemoveEntryButton: () => cy.byTestID('remove-entry-button').first().click(), - clickRevealValues: () => { - // Wait for page to fully stabilize - cy.byTestID('loading-indicator', { timeout: 5000 }).should('not.exist'); - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.wait(1000); - // Click reveal-values button with force to handle re-renders - cy.byTestID('reveal-values', { timeout: 30000 }).should('be.visible').click({ force: true }); - // Wait for data to be revealed - cy.byTestID('secret-data', { timeout: 10000 }).should('be.visible'); - }, - clickCreateSecretDropdownButton: (secretType: string) => { - cy.byTestID('item-create') - .click({ force: true }) - .get('body') - .then(($body) => { - if ($body.find(`[data-test-dropdown-menu=${secretType}]`).length) { - cy.get(`[data-test-dropdown-menu=${secretType}]`).click(); - } - }); - }, - deleteSecret: (secretName: string) => { - detailsPage.clickPageActionFromDropdown('Delete Secret'); - modal.shouldBeOpened(); - modal.submit(); - modal.shouldBeClosed(); - listPage.rows.shouldNotExist(secretName); - }, - detailsPageIsLoaded: (secretName: string) => { - // Wait for loading to complete - cy.byTestID('loading-indicator', { timeout: 5000 }).should('not.exist'); - detailsPage.isLoaded(); - detailsPage.titleShouldContain(secretName); - // Wait for either secret-data (has data) or empty-box (no data) to be visible - cy.get('[data-test="secret-data"], .pf-v6-c-empty-state', { timeout: 30000 }) - .should('exist') - .and('be.visible'); - }, - encode: (username, password) => Base64.encode(`${username}:${password}`), - enterSecretName: (secretName: string) => cy.byTestID('secret-name').type(secretName), - getResourceJSON: (name: string, namespace: string, kind: string) => - cy.exec(`oc get -o json -n ${namespace} ${kind} ${name}`), - save: () => { - cy.byTestID('save-changes', { timeout: 10000 }) - .should('be.visible') - .and('not.be.disabled') - .click(); - // Wait for navigation away from create/edit page - cy.byTestID('save-changes').should('not.exist'); - }, -}; diff --git a/frontend/public/components/modals/add-secret-to-workload.tsx b/frontend/public/components/modals/add-secret-to-workload.tsx index f38cf2d8378..4a41284b988 100644 --- a/frontend/public/components/modals/add-secret-to-workload.tsx +++ b/frontend/public/components/modals/add-secret-to-workload.tsx @@ -210,7 +210,7 @@ const AddSecretToWorkloadModal: FC = (props) => { autocompleteFilter={autocompleteFilter} autocompletePlaceholder={selectWorkloadPlaceholder} id="co-add-secret-to-workload__workload" - data-test="add-secret-to-workload-button" + dataTest="add-secret-to-workload-button" /> = ({ {t('Basic authentication')} - + {t('SSH key')} diff --git a/frontend/public/components/secrets/create-secret/PullSecretCredentialEntry.tsx b/frontend/public/components/secrets/create-secret/PullSecretCredentialEntry.tsx index 30fbc57678b..ffcad81f9b0 100644 --- a/frontend/public/components/secrets/create-secret/PullSecretCredentialEntry.tsx +++ b/frontend/public/components/secrets/create-secret/PullSecretCredentialEntry.tsx @@ -48,7 +48,11 @@ export const PullSecretCredentialEntry: FC = ({ ); return ( - + {showRemoveButton && (