diff --git a/frontend/e2e/clients/kubernetes-client.ts b/frontend/e2e/clients/kubernetes-client.ts index 9b3bbc49b4f..6de5cc0d3e2 100644 --- a/frontend/e2e/clients/kubernetes-client.ts +++ b/frontend/e2e/clients/kubernetes-client.ts @@ -389,15 +389,29 @@ export default class KubernetesClient { }); } + async createSecret(namespace: string, body: k8s.V1Secret): Promise; + async createSecret(name: string, namespace: string, data?: Record): Promise; async createSecret( - name: string, - namespace: string, - data: Record = {}, + nameOrNamespace: string, + namespaceOrBody: string | k8s.V1Secret, + data?: Record, ): Promise { - await this.k8sApi.createNamespacedSecret({ - namespace, - body: { apiVersion: 'v1', kind: 'Secret', metadata: { name, namespace }, data }, - }); + if (typeof namespaceOrBody === 'object') { + await this.k8sApi.createNamespacedSecret({ + namespace: nameOrNamespace, + body: namespaceOrBody, + }); + } else { + await this.k8sApi.createNamespacedSecret({ + namespace: namespaceOrBody, + body: { + apiVersion: 'v1', + kind: 'Secret', + metadata: { name: nameOrNamespace, namespace: namespaceOrBody }, + data: data ?? {}, + }, + }); + } } async mergePatchResource(apiPath: string, patch: object): Promise { @@ -479,6 +493,10 @@ export default class KubernetesClient { } } + async getSecret(name: string, namespace: string): Promise { + return this.k8sApi.readNamespacedSecret({ name, namespace }); + } + async patchSecret(name: string, namespace: string, patch: object[]): Promise { await this.k8sApi.patchNamespacedSecret({ name, namespace, body: patch }); } @@ -573,6 +591,20 @@ export default class KubernetesClient { return response; } + async getDeployment(name: string, namespace: string): Promise { + return this.appsApi.readNamespacedDeployment({ name, namespace }); + } + + async deleteDeployment(name: string, namespace: string): Promise { + try { + await this.appsApi.deleteNamespacedDeployment({ name, namespace }); + } catch (err) { + if (!isNotFound(err)) { + throw err; + } + } + } + async listCustomResources( group: string, version: string, diff --git a/frontend/packages/integration-tests/fixtures/asciisecret.txt b/frontend/e2e/fixtures/secrets/asciisecret.txt similarity index 100% rename from frontend/packages/integration-tests/fixtures/asciisecret.txt rename to frontend/e2e/fixtures/secrets/asciisecret.txt diff --git a/frontend/packages/integration-tests/fixtures/binarysecret.bin b/frontend/e2e/fixtures/secrets/binarysecret.bin similarity index 100% rename from frontend/packages/integration-tests/fixtures/binarysecret.bin rename to frontend/e2e/fixtures/secrets/binarysecret.bin diff --git a/frontend/packages/integration-tests/fixtures/unicodesecret.utf8 b/frontend/e2e/fixtures/secrets/unicodesecret.utf8 similarity index 100% rename from frontend/packages/integration-tests/fixtures/unicodesecret.utf8 rename to frontend/e2e/fixtures/secrets/unicodesecret.utf8 diff --git a/frontend/e2e/pages/secret-page.ts b/frontend/e2e/pages/secret-page.ts new file mode 100644 index 00000000000..23df05b4f5c --- /dev/null +++ b/frontend/e2e/pages/secret-page.ts @@ -0,0 +1,275 @@ +import type { Locator } from '@playwright/test'; +import { expect } from '@playwright/test'; + +import BasePage from './base-page'; +import { ModalPage } from './modal-page'; + +export class SecretPage extends BasePage { + private readonly secretNameInput = this.page.getByTestId('secret-name'); + private readonly saveButton = this.page.getByTestId('save-changes'); + private readonly revealValuesButton = this.page.getByTestId('reveal-values'); + private readonly secretDataContainer = this.page.getByTestId('secret-data'); + private readonly addCredentialsButton = this.page.getByTestId('add-credentials-button'); + private readonly removeEntryButton = this.page.getByTestId('remove-entry-button'); + private readonly authTypeToggle = this.page.getByTestId( + 'console-select-auth-type-menu-toggle', + ); + private readonly webhookGenerateButton = this.page.getByTestId('webhook-generate-button'); + private readonly secretKeyInput = this.page.getByTestId('secret-key'); + private readonly fileInputTextarea = this.page.getByRole('textbox', { + name: 'Value', + exact: true, + }); + private readonly binaryAlert = this.page.getByTestId('file-input-binary-alert'); + private readonly pageHeading = this.page.getByTestId('page-heading'); + private readonly createDropdown = this.page.getByTestId('item-create'); + private readonly actionsButton = this.page.getByTestId('actions-menu-button'); + private readonly secretDataTerms = this.page.getByTestId('secret-data-term'); + private readonly clipboards = this.page.getByTestId('copy-to-clipboard'); + private readonly imagePullForm = this.page.getByTestId('create-image-secret-form'); + private readonly usernameInput = this.page.getByTestId('secret-username'); + private readonly passwordInput = this.page.getByTestId('secret-password'); + private readonly sshKeyTextarea = this.page.getByTestId('ssh-privatekey-textarea'); + private readonly dockerConfigTextarea = this.page.getByTestId('docker-config-textarea'); + private readonly imagePasswordInput = this.page.getByTestId('image-secret-password'); + private readonly cancelButton = this.page.getByRole('button', { name: 'Cancel' }); + private readonly modal = new ModalPage(this.page); + + getPageHeading(): Locator { + return this.pageHeading; + } + + getBinaryAlert(): Locator { + return this.binaryAlert; + } + + getClipboards(): Locator { + return this.clipboards; + } + + getImagePullForms(): Locator { + return this.imagePullForm; + } + + getUsernameInput(): Locator { + return this.usernameInput; + } + + getPasswordInput(): Locator { + return this.passwordInput; + } + + getSshKeyTextarea(): Locator { + return this.sshKeyTextarea; + } + + getDockerConfigTextarea(): Locator { + return this.dockerConfigTextarea; + } + + getImagePasswordInput(): Locator { + return this.imagePasswordInput; + } + + getFileInputTextarea(): Locator { + return this.fileInputTextarea; + } + + getSecretKeyInput(): Locator { + return this.secretKeyInput; + } + + async clickCreateSecretDropdownButton(secretType: string): Promise { + await this.robustClick(this.createDropdown); + const menuItem = this.page.getByTestId(`dropdown-menu-${secretType}`).getByRole('menuitem'); + await this.robustClick(menuItem); + await this.waitForLoadingComplete(); + } + + async fillName(name: string): Promise { + await this.secretNameInput.fill(name); + } + + async fillSecretKey(key: string): Promise { + await this.secretKeyInput.fill(key); + } + + async save(): Promise { + await expect(this.saveButton).toBeEnabled(); + await this.robustClick(this.saveButton); + // eslint-disable-next-line no-restricted-syntax + await this.saveButton.waitFor({ state: 'detached', timeout: 30_000 }); + } + + async revealValues(): Promise { + await this.waitForLoadingComplete(); + await this.robustClick(this.revealValuesButton, { timeout: 30_000 }); + await expect(this.secretDataContainer).toBeVisible(); + } + + async verifySecretData(expected: Record, json = false): Promise { + await this.revealValues(); + const count = await this.secretDataTerms.count(); + const rendered: Record = {}; + for (let i = 0; i < count; i++) { + const key = (await this.secretDataTerms.nth(i).textContent()) ?? ''; + const value = (await this.clipboards.nth(i).textContent()) ?? ''; + rendered[key] = json ? JSON.parse(value) : value; + } + expect(rendered).toEqual(expected); + } + + async checkKeyValueExist(key: string, value: string): Promise { + await this.revealValues(); + const count = await this.secretDataTerms.count(); + for (let i = 0; i < count; i++) { + const termText = (await this.secretDataTerms.nth(i).textContent()) ?? ''; + if (termText === key) { + await expect(this.clipboards.nth(i)).toContainText(value); + return; + } + } + throw new Error(`Secret key "${key}" not found among ${count} entries`); + } + + async addKeyValueEntry(key: string, value: string): Promise { + await this.robustClick(this.addCredentialsButton); + await this.secretKeyInput.last().clear(); + await this.secretKeyInput.last().fill(key); + await this.fileInputTextarea.last().clear(); + await this.fileInputTextarea.last().fill(value); + } + + async fillBasicAuth(username: string, password: string): Promise { + await this.usernameInput.fill(username); + await this.passwordInput.fill(password); + } + + async fillSshKey(key: string): Promise { + await this.sshKeyTextarea.fill(key); + } + + async fillDockerConfig(config: string): Promise { + await this.dockerConfigTextarea.fill(config); + } + + async fillImagePullCredential( + index: number, + cred: { address: string; username: string; password: string; email: string }, + ): Promise { + const form = this.imagePullForm.nth(index); + await form.getByTestId('image-secret-address').fill(cred.address); + await form.getByTestId('image-secret-username').fill(cred.username); + await form.getByTestId('image-secret-password').fill(cred.password); + await form.getByTestId('image-secret-email').fill(cred.email); + } + + async addCredentialEntry(): Promise { + await this.robustClick(this.addCredentialsButton); + } + + async removeEntry(index = 0): Promise { + await this.robustClick(this.removeEntryButton.nth(index)); + } + + async cancel(): Promise { + await this.robustClick(this.cancelButton); + } + + async selectAuthType(type: string): Promise { + const option = this.page.getByTestId(type).getByRole('option'); + for (let attempt = 0; attempt < 3; attempt++) { + await this.robustClick(this.authTypeToggle); + try { + await option.click({ timeout: 5_000 }); + return; + } catch { + // Dropdown may have closed — retry + } + } + throw new Error(`Auth type option "${type}" not found after 3 attempts`); + } + + async generateWebhookKey(): Promise { + await this.robustClick(this.webhookGenerateButton); + } + + async detailsPageIsLoaded(secretName: string): Promise { + await this.waitForLoadingComplete(); + await expect(this.pageHeading).toContainText(secretName, { timeout: 30_000 }); + const dataOrEmpty = this.secretDataContainer.or(this.page.getByTestId('empty-box')); + 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; + } + await expect(dataOrEmpty.first()).toBeVisible({ timeout: 30_000 }); + } + + private async clickAction(actionName: string): Promise { + const action = this.page.getByTestId(actionName); + for (let attempt = 0; attempt < 3; attempt++) { + await this.robustClick(this.actionsButton); + try { + await action.click({ timeout: 5_000 }); + return; + } catch { + // Menu may have closed or items not loaded yet — retry + } + } + throw new Error(`Action "${actionName}" not found after 3 attempts`); + } + + async editSecret(): Promise { + await this.clickAction('Edit Secret'); + } + + async deleteSecret(): Promise { + await this.clickAction('Delete Secret'); + await this.modal.waitForOpen(); + await this.modal.submit(); + await this.modal.waitForClosed(); + } + + async addToWorkload( + workloadName: string, + asType: 'environment' | 'volume', + options?: { prefix?: string; mountPath?: string }, + ): Promise { + await this.clickAction('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 expect(this.modal.getSubmitButton()).toBeEnabled(); + await this.modal.submit(); + await this.modal.waitForClosed(); + } + + async uploadFile(filePath: string): Promise { + const fileInput = this.page.locator('.co-file-input input[type="file"]'); + await fileInput.setInputFiles(filePath); + } + + static encode(username: string, password: string): string { + return Buffer.from(`${username}:${password}`).toString('base64'); + } +} 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..6c86e05facf --- /dev/null +++ b/frontend/e2e/tests/console/crud/secrets/add-to-workload.spec.ts @@ -0,0 +1,117 @@ +import { test, expect } from '../../../../fixtures'; +import { warmupSPA } from '../../../../pages/base-page'; +import { SecretPage } from '../../../../pages/secret-page'; + +test.describe('Add Secret to Workloads', { tag: ['@admin', '@crud'] }, () => { + test('adds secret to deployment as environment variables', async ({ + page, + k8sClient, + cleanup, + }) => { + const ns = `test-secret-env-${Date.now()}`; + const secretName = 'test-secret'; + const deployName = 'test-deploy'; + const envPrefix = 'env-'; + const secretPage = new SecretPage(page); + + await test.step('Set up namespace, deployment, and secret', async () => { + await k8sClient.createNamespace(ns); + cleanup.trackNamespace(ns); + await k8sClient.createDeployment(ns, { + apiVersion: 'apps/v1', + kind: 'Deployment', + metadata: { name: deployName, namespace: ns }, + 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', + }, + ], + }, + }, + }, + } as any); + await k8sClient.createSecret(ns, { + apiVersion: 'v1', + kind: 'Secret', + metadata: { name: secretName, namespace: ns }, + stringData: { key1: 'supersecret' }, + } as any); + }); + + await test.step('Add secret as env vars', async () => { + await warmupSPA(page); + await page.goto(`/k8s/ns/${ns}/secrets/${secretName}`); + await secretPage.detailsPageIsLoaded(secretName); + await secretPage.addToWorkload(deployName, 'environment', { prefix: envPrefix }); + }); + + await test.step('Verify env vars via API', async () => { + const deploy = (await k8sClient.getDeployment(deployName, ns)) as any; + const envFrom = deploy.spec.template.spec.containers[0].envFrom; + expect(envFrom).toBeDefined(); + const secretEnv = envFrom.find((e: any) => e.secretRef?.name === secretName); + expect(secretEnv).toBeDefined(); + expect(secretEnv.prefix).toBe(envPrefix); + }); + }); + + test('adds secret to deployment as volume', async ({ page, k8sClient, cleanup }) => { + const ns = `test-secret-vol-${Date.now()}`; + const secretName = 'test-secret'; + const deployName = 'test-deploy'; + const mountPath = '/tmp/testdata'; + const secretPage = new SecretPage(page); + + await test.step('Set up namespace, deployment, and secret', async () => { + await k8sClient.createNamespace(ns); + cleanup.trackNamespace(ns); + await k8sClient.createDeployment(ns, { + apiVersion: 'apps/v1', + kind: 'Deployment', + metadata: { name: deployName, namespace: ns }, + 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', + }, + ], + }, + }, + }, + } as any); + await k8sClient.createSecret(ns, { + apiVersion: 'v1', + kind: 'Secret', + metadata: { name: secretName, namespace: ns }, + stringData: { key1: 'supersecret' }, + } as any); + }); + + await test.step('Add secret as volume', async () => { + await warmupSPA(page); + await page.goto(`/k8s/ns/${ns}/secrets/${secretName}`); + await secretPage.detailsPageIsLoaded(secretName); + await secretPage.addToWorkload(deployName, 'volume', { mountPath }); + }); + + await test.step('Verify volume mount via API', async () => { + const deploy = (await k8sClient.getDeployment(deployName, ns)) as any; + const volumeMounts = deploy.spec.template.spec.containers[0].volumeMounts; + expect(volumeMounts).toBeDefined(); + const secretMount = volumeMounts.find((m: any) => m.name === secretName); + expect(secretMount).toBeDefined(); + expect(secretMount.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..9d7b7df6f7d --- /dev/null +++ b/frontend/e2e/tests/console/crud/secrets/image-pull.spec.ts @@ -0,0 +1,148 @@ +import { test, expect } from '../../../../fixtures'; +import { SecretPage } from '../../../../pages/secret-page'; + +test.describe('Image pull secrets', { tag: ['@admin', '@crud'] }, () => { + test('creates and edits registry credentials', async ({ page, k8sClient, cleanup }) => { + const ns = `test-secret-img-cred-${Date.now()}`; + const secretName = `registry-cred-secret`; + const secretPage = new SecretPage(page); + const address = 'https://index.openshift.io/v'; + const addressUpdated = 'https://index.openshift.io/updated/v1'; + + await test.step('Set up namespace', async () => { + await k8sClient.createNamespace(ns); + cleanup.trackNamespace(ns); + }); + + await test.step('Create image secret with two credential entries', async () => { + await page.goto(`/k8s/ns/${ns}/secrets`); + await secretPage.clickCreateSecretDropdownButton('image'); + await expect(secretPage.getPageHeading()).toContainText('Create image pull secret'); + await secretPage.fillName(secretName); + await secretPage.addCredentialEntry(); + + const count = await secretPage.getImagePullForms().count(); + for (let i = 0; i < count; i++) { + await secretPage.fillImagePullCredential(i, { + address: `${address}${i}`, + username: `username${i}`, + password: `password${i}`, + email: `test@secret.com${i}`, + }); + } + await secretPage.save(); + }); + + await test.step('Verify credentials via API', async () => { + await secretPage.detailsPageIsLoaded(secretName); + const secret = await k8sClient.getSecret(secretName, ns); + const dockerConfig = JSON.parse( + Buffer.from((secret as any).data['.dockerconfigjson'], 'base64').toString('utf-8'), + ); + expect(dockerConfig.auths[`${address}0`].username).toBe('username0'); + expect(dockerConfig.auths[`${address}0`].password).toBe('password0'); + expect(dockerConfig.auths[`${address}0`].auth).toBe(SecretPage.encode('username0', 'password0')); + expect(dockerConfig.auths[`${address}1`].username).toBe('username1'); + expect(dockerConfig.auths[`${address}1`].auth).toBe(SecretPage.encode('username1', 'password1')); + }); + + await test.step('Edit secret: remove entry, update with whitespace', async () => { + await secretPage.editSecret(); + await expect(secretPage.getPageHeading()).toContainText('Edit image pull secret'); + await expect(secretPage.getImagePullForms()).toHaveCount(2); + await secretPage.removeEntry(0); + await secretPage.fillImagePullCredential(0, { + address: ` ${addressUpdated} `, + username: ' usernameUpdated ', + password: ' passwordUpdated ', + email: ' testUpdated@secret.com ', + }); + await secretPage.save(); + }); + + await test.step('Verify whitespace trimmed in API', async () => { + await secretPage.detailsPageIsLoaded(secretName); + const secret = await k8sClient.getSecret(secretName, ns); + const dockerConfig = JSON.parse( + Buffer.from((secret as any).data['.dockerconfigjson'], 'base64').toString('utf-8'), + ); + expect(dockerConfig.auths[addressUpdated].username).toBe('usernameUpdated'); + expect(dockerConfig.auths[addressUpdated].password).toBe('passwordUpdated'); + expect(dockerConfig.auths[addressUpdated].email).toBe('testUpdated@secret.com'); + expect(dockerConfig.auths[addressUpdated].auth).toBe( + SecretPage.encode('usernameUpdated', 'passwordUpdated'), + ); + }); + }); + + test('creates an upload config file image pull secret', async ({ + page, + k8sClient, + cleanup, + }) => { + const ns = `test-secret-img-cfg-${Date.now()}`; + const secretName = `config-file-secret`; + const secretPage = new SecretPage(page); + const configFile = { + auths: { + 'https://index.openshift.io/v1': { + username: 'username', + password: 'password', + auth: SecretPage.encode('username', 'password'), + email: 'test@secret.com', + }, + }, + }; + + await test.step('Set up namespace', async () => { + await k8sClient.createNamespace(ns); + cleanup.trackNamespace(ns); + }); + + await test.step('Create config file secret', async () => { + await page.goto(`/k8s/ns/${ns}/secrets`); + await secretPage.clickCreateSecretDropdownButton('image'); + await expect(secretPage.getPageHeading()).toContainText('Create image pull secret'); + await secretPage.fillName(secretName); + await secretPage.selectAuthType('config-file'); + + await secretPage.fillDockerConfig(JSON.stringify(configFile)); + await secretPage.save(); + }); + + await test.step('Verify config file secret via API', async () => { + await secretPage.detailsPageIsLoaded(secretName); + const secret = await k8sClient.getSecret(secretName, ns); + const dockerConfig = JSON.parse( + Buffer.from((secret as any).data['.dockerconfigjson'], 'base64').toString('utf-8'), + ); + expect(dockerConfig).toEqual(configFile); + }); + }); + + test('obfuscates password fields', async ({ page, k8sClient, cleanup }) => { + const ns = `test-secret-img-pw-${Date.now()}`; + const secretPage = new SecretPage(page); + + await test.step('Set up namespace', async () => { + await k8sClient.createNamespace(ns); + cleanup.trackNamespace(ns); + }); + + await test.step('Verify image pull password is obfuscated', async () => { + await page.goto(`/k8s/ns/${ns}/secrets`); + await secretPage.clickCreateSecretDropdownButton('image'); + await expect( + secretPage.getImagePasswordInput(), + ).toHaveAttribute('type', 'password'); + await secretPage.cancel(); + }); + + await test.step('Verify source secret password is obfuscated', async () => { + await secretPage.clickCreateSecretDropdownButton('source'); + await expect( + secretPage.getPasswordInput(), + ).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..f5f2bc40541 --- /dev/null +++ b/frontend/e2e/tests/console/crud/secrets/key-value.spec.ts @@ -0,0 +1,200 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { test, expect } from '../../../../fixtures'; +import { warmupSPA } from '../../../../pages/base-page'; +import { SecretPage } from '../../../../pages/secret-page'; + +const fixturesDir = path.resolve(import.meta.dirname, '..', '..', '..', '..', 'fixtures', 'secrets'); + +test.describe('Key/value secrets', { tag: ['@admin', '@crud'] }, () => { + test('creates a binary file secret', async ({ page, k8sClient, cleanup }) => { + const ns = `test-secret-kv-bin-${Date.now()}`; + const secretName = `binary-secret`; + const secretKey = 'secretkey'; + const modifiedKey = 'modifiedsecretkey'; + const secretPage = new SecretPage(page); + + await test.step('Set up namespace', async () => { + await k8sClient.createNamespace(ns); + cleanup.trackNamespace(ns); + }); + + await test.step('Create binary file secret', async () => { + await page.goto(`/k8s/ns/${ns}/secrets`); + await secretPage.clickCreateSecretDropdownButton('generic'); + await expect(secretPage.getPageHeading()).toContainText('Create key/value secret'); + await secretPage.fillName(secretName); + await secretPage.fillSecretKey(secretKey); + await secretPage.uploadFile(path.join(fixturesDir, 'binarysecret.bin')); + await expect(secretPage.getBinaryAlert()).toBeVisible(); + await secretPage.save(); + }); + + await test.step('Verify binary secret via API', async () => { + await secretPage.detailsPageIsLoaded(secretName); + const secret = await k8sClient.getSecret(secretName, ns); + const expectedBase64 = fs.readFileSync(path.join(fixturesDir, 'binarysecret.bin')).toString('base64'); + expect((secret as any).data[secretKey]).toBe(expectedBase64); + }); + + await test.step('Edit secret key and verify data preserved', async () => { + await secretPage.editSecret(); + await expect(secretPage.getPageHeading()).toContainText('Edit key/value secret'); + await secretPage.getSecretKeyInput().clear(); + await secretPage.fillSecretKey(modifiedKey); + await expect(secretPage.getBinaryAlert()).toBeVisible(); + await secretPage.save(); + await secretPage.detailsPageIsLoaded(secretName); + const secret = await k8sClient.getSecret(secretName, ns); + const expectedBase64 = fs.readFileSync(path.join(fixturesDir, 'binarysecret.bin')).toString('base64'); + expect((secret as any).data[modifiedKey]).toBe(expectedBase64); + }); + }); + + test('creates an ascii file secret', async ({ page, k8sClient, cleanup }) => { + const ns = `test-secret-kv-ascii-${Date.now()}`; + const secretName = `ascii-secret`; + const secretKey = 'secretkey'; + const secretPage = new SecretPage(page); + const asciiContent = fs.readFileSync(path.join(fixturesDir, 'asciisecret.txt'), 'utf-8'); + + await test.step('Set up namespace', async () => { + await k8sClient.createNamespace(ns); + cleanup.trackNamespace(ns); + }); + + await test.step('Create ascii file secret', async () => { + await page.goto(`/k8s/ns/${ns}/secrets`); + await secretPage.clickCreateSecretDropdownButton('generic'); + await secretPage.fillName(secretName); + await secretPage.fillSecretKey(secretKey); + await secretPage.uploadFile(path.join(fixturesDir, 'asciisecret.txt')); + await expect(secretPage.getFileInputTextarea()).toContainText(asciiContent); + await expect(secretPage.getBinaryAlert()).toBeHidden(); + await secretPage.save(); + }); + + await test.step('Verify ascii secret via API', async () => { + await secretPage.detailsPageIsLoaded(secretName); + const secret = await k8sClient.getSecret(secretName, ns); + const decoded = Buffer.from((secret as any).data[secretKey], 'base64').toString('utf-8'); + expect(decoded).toBe(asciiContent); + }); + }); + + test('creates a unicode file secret', async ({ page, k8sClient, cleanup }) => { + const ns = `test-secret-kv-unicode-${Date.now()}`; + const secretName = `unicode-secret`; + const secretKey = 'secretkey'; + const secretPage = new SecretPage(page); + const unicodeContent = fs.readFileSync(path.join(fixturesDir, 'unicodesecret.utf8'), 'utf-8'); + + await test.step('Set up namespace', async () => { + await k8sClient.createNamespace(ns); + cleanup.trackNamespace(ns); + }); + + await test.step('Create unicode file secret', async () => { + await page.goto(`/k8s/ns/${ns}/secrets`); + await secretPage.clickCreateSecretDropdownButton('generic'); + await secretPage.fillName(secretName); + await secretPage.fillSecretKey(secretKey); + await secretPage.uploadFile(path.join(fixturesDir, 'unicodesecret.utf8')); + await expect(secretPage.getFileInputTextarea()).toContainText(unicodeContent); + await expect(secretPage.getBinaryAlert()).toBeHidden(); + await secretPage.save(); + }); + + await test.step('Verify unicode secret via API', async () => { + await secretPage.detailsPageIsLoaded(secretName); + const secret = await k8sClient.getSecret(secretName, ns); + const decoded = Buffer.from((secret as any).data[secretKey], 'base64').toString('utf-8'); + expect(decoded).toBe(unicodeContent); + }); + }); + + test('edits a TLS secret', async ({ page, k8sClient, cleanup }) => { + const ns = `test-secret-kv-tls-${Date.now()}`; + const secretName = `tls-secret`; + const secretPage = new SecretPage(page); + + await test.step('Set up namespace and TLS secret', async () => { + await k8sClient.createNamespace(ns); + cleanup.trackNamespace(ns); + await k8sClient.createSecret(ns, { + apiVersion: 'v1', + kind: 'Secret', + metadata: { name: secretName, namespace: ns }, + type: 'kubernetes.io/tls', + data: { + 'tls.crt': 'QUFBCG==', + 'tls.key': 'QkJCCg==', + }, + } as any); + }); + + await test.step('Edit TLS secret and add key', async () => { + await page.goto(`/k8s/ns/${ns}/secrets/${secretName}/edit`); + await secretPage.addKeyValueEntry('keyfortest', 'valuefortest'); + await secretPage.save(); + }); + + await test.step('Verify new key appears on details page', async () => { + await secretPage.detailsPageIsLoaded(secretName); + await secretPage.checkKeyValueExist('keyfortest', 'valuefortest'); + const secret = await k8sClient.getSecret(secretName, ns); + expect((secret as any).data).toBeDefined(); + }); + }); + + test('editing text field does not corrupt binary data (OCPBUGS-70273)', async ({ + page, + k8sClient, + cleanup, + }) => { + const ns = `test-secret-kv-mixed-${Date.now()}`; + const secretName = 'mixed-secret'; + const textKey = 'textfield'; + const textValue = 'original-password'; + const updatedTextValue = 'updated-password'; + const binaryKey = 'binaryfield'; + const binaryBase64 = fs.readFileSync(path.join(fixturesDir, 'binarysecret.bin')).toString('base64'); + const secretPage = new SecretPage(page); + + await test.step('Set up namespace and mixed secret', async () => { + await k8sClient.createNamespace(ns); + cleanup.trackNamespace(ns); + await k8sClient.createSecret(ns, { + apiVersion: 'v1', + kind: 'Secret', + metadata: { name: secretName, namespace: ns }, + data: { + [textKey]: Buffer.from(textValue).toString('base64'), + [binaryKey]: binaryBase64, + }, + } as any); + }); + + await test.step('Edit only the text field', async () => { + await warmupSPA(page); + await page.goto(`/k8s/ns/${ns}/secrets/${secretName}`); + await secretPage.detailsPageIsLoaded(secretName); + await secretPage.editSecret(); + await expect(secretPage.getPageHeading()).toContainText('Edit key/value secret'); + await expect(secretPage.getBinaryAlert()).toBeVisible(); + const textArea = secretPage.getFileInputTextarea().first(); + await textArea.clear(); + await textArea.fill(updatedTextValue); + await secretPage.save(); + }); + + await test.step('Verify text updated and binary preserved', async () => { + await secretPage.detailsPageIsLoaded(secretName); + await secretPage.revealValues(); + await expect(secretPage.getClipboards().first()).toContainText(updatedTextValue); + const secret = await k8sClient.getSecret(secretName, ns); + expect((secret as any).data[binaryKey]).toBe(binaryBase64); + }); + }); +}); 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..78c90577032 --- /dev/null +++ b/frontend/e2e/tests/console/crud/secrets/source.spec.ts @@ -0,0 +1,114 @@ +import { test, expect } from '../../../../fixtures'; +import { SecretPage } from '../../../../pages/secret-page'; + +test.describe('Source secrets', { tag: ['@admin', '@crud'] }, () => { + test('creates, edits, and deletes a basic source secret', async ({ + page, + k8sClient, + cleanup, + }) => { + const ns = `test-secret-src-basic-${Date.now()}`; + const secretName = `basic-source-secret`; + const secretPage = new SecretPage(page); + + await test.step('Set up namespace', async () => { + await k8sClient.createNamespace(ns); + cleanup.trackNamespace(ns); + }); + + await test.step('Create basic source secret', async () => { + await page.goto(`/k8s/ns/${ns}/secrets`); + await secretPage.clickCreateSecretDropdownButton('source'); + await expect(secretPage.getPageHeading()).toContainText('Create source secret'); + await secretPage.fillName(secretName); + await secretPage.fillBasicAuth('username', 'password'); + await secretPage.save(); + }); + + await test.step('Verify secret data', async () => { + await secretPage.detailsPageIsLoaded(secretName); + await secretPage.verifySecretData({ + password: 'password', + username: 'username', + }); + }); + + await test.step('Edit secret', async () => { + await secretPage.editSecret(); + await expect(secretPage.getPageHeading()).toContainText('Edit source secret'); + await expect(secretPage.getUsernameInput()).toHaveValue('username'); + await expect(secretPage.getPasswordInput()).toHaveValue('password'); + await secretPage.getUsernameInput().clear(); + await secretPage.getUsernameInput().fill('usernameUpdated'); + await secretPage.getPasswordInput().clear(); + await secretPage.getPasswordInput().fill('passwordUpdated'); + await secretPage.save(); + }); + + await test.step('Verify edit', async () => { + await secretPage.detailsPageIsLoaded(secretName); + await secretPage.verifySecretData({ + password: 'passwordUpdated', + username: 'usernameUpdated', + }); + }); + + await test.step('Delete secret', async () => { + await secretPage.deleteSecret(); + }); + }); + + test('creates, edits, and deletes an SSH source secret', async ({ + page, + k8sClient, + cleanup, + }) => { + const ns = `test-secret-src-ssh-${Date.now()}`; + const secretName = `ssh-source-secret`; + const sshKey = 'sshKey'; + const sshKeyUpdated = 'sshKeyUpdated'; + const secretPage = new SecretPage(page); + + await test.step('Set up namespace', async () => { + await k8sClient.createNamespace(ns); + cleanup.trackNamespace(ns); + }); + + await test.step('Create SSH source secret', async () => { + await page.goto(`/k8s/ns/${ns}/secrets`); + await secretPage.clickCreateSecretDropdownButton('source'); + await expect(secretPage.getPageHeading()).toContainText('Create source secret'); + await secretPage.fillName(secretName); + await secretPage.selectAuthType('kubernetes.io/ssh-auth'); + await secretPage.fillSshKey(sshKey); + await secretPage.save(); + }); + + await test.step('Verify secret data', async () => { + await secretPage.detailsPageIsLoaded(secretName); + await secretPage.verifySecretData({ + 'ssh-privatekey': `${sshKey}\n`, + }); + }); + + await test.step('Edit secret', async () => { + await secretPage.editSecret(); + await expect(secretPage.getPageHeading()).toContainText('Edit source secret'); + await expect(secretPage.getSshKeyTextarea()).toContainText(sshKey); + await secretPage.getSshKeyTextarea().clear(); + await secretPage.fillSshKey(sshKeyUpdated); + await secretPage.save(); + }); + + await test.step('Verify edit', async () => { + await secretPage.detailsPageIsLoaded(secretName); + await secretPage.verifySecretData({ + 'ssh-privatekey': `${sshKeyUpdated}\n`, + }); + }); + + await test.step('Delete secret', async () => { + await secretPage.deleteSecret(); + }); + }); +}); 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..3d65fa4a898 --- /dev/null +++ b/frontend/e2e/tests/console/crud/secrets/webhook.spec.ts @@ -0,0 +1,53 @@ +import { test, expect } from '../../../../fixtures'; +import { SecretPage } from '../../../../pages/secret-page'; + +test.describe('Webhook secret', { tag: ['@admin', '@crud'] }, () => { + test('creates, regenerates, and deletes a webhook secret', async ({ + page, + k8sClient, + cleanup, + }) => { + const ns = `test-secret-webhook-${Date.now()}`; + const secretName = `webhook-secret`; + const webhookKey = 'webhookValue'; + const secretPage = new SecretPage(page); + + await test.step('Set up namespace', async () => { + await k8sClient.createNamespace(ns); + cleanup.trackNamespace(ns); + }); + + await test.step('Create webhook secret', async () => { + await page.goto(`/k8s/ns/${ns}/secrets`); + await secretPage.clickCreateSecretDropdownButton('webhook'); + await expect(secretPage.getPageHeading()).toContainText('Create webhook secret'); + await secretPage.fillName(secretName); + await secretPage.fillSecretKey(webhookKey); + await secretPage.save(); + }); + + await test.step('Verify webhook secret', async () => { + await secretPage.detailsPageIsLoaded(secretName); + await secretPage.verifySecretData({ + WebHookSecretKey: webhookKey, + }); + }); + + await test.step('Edit and regenerate key', async () => { + await secretPage.editSecret(); + await expect(secretPage.getPageHeading()).toContainText('Edit webhook secret'); + await secretPage.generateWebhookKey(); + await secretPage.save(); + }); + + await test.step('Verify key changed', async () => { + await secretPage.detailsPageIsLoaded(secretName); + await secretPage.revealValues(); + await expect(secretPage.getClipboards().first()).not.toHaveText(webhookKey); + }); + + await test.step('Delete secret', async () => { + await secretPage.deleteSecret(); + }); + }); +}); 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 8b41473d1cf..00000000000 --- a/frontend/packages/integration-tests/views/secret.ts +++ /dev/null @@ -1,96 +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) => { - return 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 210558aa6de..d2867bc047f 100644 --- a/frontend/public/components/modals/add-secret-to-workload.tsx +++ b/frontend/public/components/modals/add-secret-to-workload.tsx @@ -207,7 +207,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 87a24943262..264fbd90a51 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 && (