Repository: canonical/juju-dashboard
Pull request #1971: chore(ci): Add secrets e2e test
Author: login: huwshimi
Labels: name: Review: Code +1 (with changes)
name: e2e
description: Run E2E tests on this PR.
Created: 2025-05-14T02:49:49Z | Merged: 2025-05-15T10:05:18Z

## Done

- Branched from https://github.com/canonical/juju-dashboard/pull/1964.
- Use label and test id enums.
- Use the reloadDashboard method.
- Update user credentials to use the credentials from the client.

Passing run:
https://github.com/canonical/juju-dashboard/actions/runs/15036671442/job/42259616341?pr=1971

## Details

https://warthogs.atlassian.net/browse/WD-20948



Diff:
diff --git a/.github/workflows/e2e-juju-k8s-charm-local-auth.yml b/.github/workflows/e2e-juju-k8s-charm-local-auth.yml
index 8883b08df..64f091a62 100644
--- a/.github/workflows/e2e-juju-k8s-charm-local-auth.yml
+++ b/.github/workflows/e2e-juju-k8s-charm-local-auth.yml
@@ -26,7 +26,7 @@ jobs:
         uses: charmed-kubernetes/actions-operator@main
         with:
           provider: "microk8s"
-          channel: 1.28-strict/stable
+          channel: 1.32-strict/stable
           juju-channel: ${{ steps.config.outputs.juju-channel }}
           microk8s-group: snap_microk8s
       - name: Save microk8s controller name
diff --git a/e2e/base/actions.spec.ts b/e2e/base/actions.spec.ts
index a06c75259..efedefb9c 100644
--- a/e2e/base/actions.spec.ts
+++ b/e2e/base/actions.spec.ts
@@ -6,7 +6,6 @@ import {
 } from "pages/EntityDetails/App/types";
 import { Label as ConfirmationDialogLabel } from "panels/ActionsPanel/ConfirmationDialog/types";
 import { TestId as ActionsPanelTestId } from "panels/ActionsPanel/types";
-import { ModelTab } from "urls";
 import urls from "urls";
 
 import { test } from "../fixtures/setup";
@@ -27,6 +26,7 @@ test.describe("Actions", () => {
   let application: Application;
 
   test.beforeAll(async ({ jujuCLI, testOptions }) => {
+    // Give the test enough time to deploy the application:
     test.setTimeout(300000);
     actions = new ActionStack(jujuCLI);
 
@@ -74,13 +74,8 @@ test.describe("Actions", () => {
       page.getByTestId(ActionsPanelTestId.PANEL),
     ).not.toBeInViewport();
 
-    // Go to the action logs
-    if (process.env.AUTH_MODE === "oidc") {
-      await page.getByRole("link", { name: ModelTab.LOGS }).click();
-    } else {
-      await page.getByTestId(AppTestId.SHOW_LOGS).click();
-    }
-
+    // Go to the action logs and verify that the action was executed
+    await page.getByTestId(AppTestId.SHOW_LOGS).click();
     await expect(
       page
         .locator("tr", { hasText: application.name })
diff --git a/e2e/base/configure.spec.ts b/e2e/base/configure.spec.ts
index 9220914c7..beda79cee 100644
--- a/e2e/base/configure.spec.ts
+++ b/e2e/base/configure.spec.ts
@@ -26,6 +26,7 @@ test.describe("configure application", () => {
   let application: Application;
 
   test.beforeAll(async ({ jujuCLI, testOptions }) => {
+    // Give the test enough time to deploy the application:
     test.setTimeout(300000);
     actions = new ActionStack(jujuCLI);
 
diff --git a/e2e/base/secrets.spec.ts b/e2e/base/secrets.spec.ts
index df6042f7c..1712984dc 100644
--- a/e2e/base/secrets.spec.ts
+++ b/e2e/base/secrets.spec.ts
@@ -1,21 +1,45 @@
 import { expect } from "@playwright/test";
 
-import { ModelTab } from "urls";
+import { Label as AppLabel } from "pages/EntityDetails/App/types";
+import { Label as ModelTabsLabel } from "pages/EntityDetails/Model/ModelTabs/types";
+import {
+  Label as SecretsTableLabel,
+  TestId as SecretsTableTestId,
+} from "pages/EntityDetails/Model/Secrets/SecretsTable/types";
+import { Label as SecretsLabel } from "pages/EntityDetails/Model/Secrets/types";
+import { Label as ConfirmationDialogLabel } from "panels/ConfigPanel/ConfirmationDialog/types";
+import {
+  Label as ConfigPanelLabel,
+  TestId as ConfigPanelTestId,
+} from "panels/ConfigPanel/types";
+import {
+  Label as SecretFormPanelLabel,
+  TestId as SecretFormPanelTestId,
+} from "panels/SecretFormPanel/types";
+import urls, { ModelTab } from "urls";
 
 import { test } from "../fixtures/setup";
 import { ActionStack } from "../helpers/action";
-import { AddModel, GiveModelAccess } from "../helpers/actions";
+import {
+  AddModel,
+  DeployApplication,
+  GiveModelAccess,
+} from "../helpers/actions";
 import type { User } from "../helpers/auth";
-import type { Model } from "../helpers/objects";
 import { ModelPermission } from "../helpers/objects";
+import type { Application, Model } from "../helpers/objects";
+import { generateRandomName } from "../utils";
 
 test.describe("secrets", () => {
   let actions: ActionStack;
   let user: User;
   let nonAdmin: User;
   let model: Model;
+  let application: Application;
 
-  test.beforeAll(async ({ jujuCLI }) => {
+  test.beforeAll(async ({ jujuCLI, testOptions }) => {
+    // Give the test enough time to deploy the application:
+    test.setTimeout(300000);
     actions = new ActionStack(jujuCLI);
 
     await actions.prepare((add) => {
@@ -23,6 +47,7 @@ test.describe("secrets", () => {
       nonAdmin = add(jujuCLI.createUser());
       model = add(new AddModel(user));
       add(new GiveModelAccess(model, nonAdmin, ModelPermission.READ));
+      application = add(new DeployApplication(model, testOptions.provider));
     });
   });
 
@@ -30,12 +55,97 @@ test.describe("secrets", () => {
     await actions.rollback();
   });
 
-  test("secrets do not display controls", async ({ page }) => {
+  test("Can create and use a secret", async ({ page }) => {
+    await user.dashboardLogin(page, urls.models.index);
+
+    // Go to the secrets tab inside the model
+    await page.getByRole("link", { name: model.name }).click();
+    await page.getByRole("link", { name: ModelTabsLabel.SECRETS }).click();
+
+    await page.getByRole("button", { name: SecretsLabel.ADD }).click();
+    await expect(
+      page.getByTestId(SecretFormPanelTestId.PANEL),
+    ).toBeInViewport();
+
+    // Add a secret
+    const secret = generateRandomName("secret");
+    await page.getByRole("textbox", { name: "Label" }).fill(secret);
+    await page.getByRole("textbox", { name: "Key 1" }).fill("somekey");
+    await page.locator("textarea[aria-label='Value 1']").fill("somevalue");
+    await page
+      .getByTestId(SecretFormPanelTestId.PANEL)
+      .getByRole("button", { name: SecretFormPanelLabel.SUBMIT_ADD })
+      .click();
+
+    // Verify the secret was added and copy the URI
+    await expect(
+      page.getByTestId(SecretFormPanelTestId.PANEL),
+    ).not.toBeInViewport();
+    await expect(page.locator("tr", { hasText: secret })).toBeInViewport();
+    const secretID = await page
+      .locator("tr", { hasText: secret })
+      .locator("td.has-hover")
+      .innerText();
+    const secretURI = `secret:${secretID}`;
+
+    // Go to the application inside the model
+    await page.getByRole("link", { name: ModelTabsLabel.APPLICATIONS }).click();
+    await page.getByRole("link", { name: application.name }).click();
+
+    await page.getByRole("button", { name: AppLabel.CONFIGURE }).click();
+    await expect(page.getByTestId(ConfigPanelTestId.PANEL)).toBeInViewport();
+
+    // Add the URI into the configuration
+    await page.getByTestId(application.config).locator("textarea").focus();
+    await page
+      .getByTestId(application.config)
+      .locator("textarea")
+      .fill(secretURI);
+    await page
+      .getByRole("button", { name: ConfigPanelLabel.SAVE_BUTTON })
+      .click();
+
+    await expect(
+      page.getByText(ConfirmationDialogLabel.SAVE_CONFIRM),
+    ).toBeInViewport();
+    await page
+      .getByRole("button", {
+        name: ConfirmationDialogLabel.SAVE_CONFIRM_CONFIRM_BUTTON,
+      })
+      .click();
+
+    await page
+      .getByRole("button", {
+        name: ConfirmationDialogLabel.GRANT_CONFIRM_BUTTON,
+      })
+      .click();
+
+    await expect(
+      page.getByTestId(ConfigPanelTestId.PANEL),
+    ).not.toBeInViewport();
+
+    // Reload the page and verify the secret was saved
+    await user.reloadDashboard(page);
+    await page.getByRole("button", { name: AppLabel.CONFIGURE }).click();
+    await expect(page.getByTestId(ConfigPanelTestId.PANEL)).toBeInViewport();
+    await page
+      .getByTestId(application.config)
+      .locator("textarea")
+      .scrollIntoViewIfNeeded();
+    await expect(
+      page.getByTestId(application.config).locator("textarea"),
+    ).toHaveText(secretURI);
+  });
+
+  test("secrets do not display controls to non-admins", async ({ page }) => {
     await nonAdmin.dashboardLogin(page, model.tab(ModelTab.SECRETS));
-    // Go to the application inside the model:
-    await page.getByRole("link", { name: model.name }).hover();
     await expect(
-      page.getByRole("button", { name: "Access" }),
+      page.getByRole("button", { name: SecretsLabel.ADD }),
+    ).not.toBeVisible();
+    await expect(
+      page
+        .getByTestId(SecretsTableTestId.SECRETS_TABLE)
+        .getByRole("button", { name: SecretsTableLabel.ACTION_MENU }),
     ).not.toBeVisible();
   });
 });
diff --git a/e2e/helpers/actions/deployApplication.ts b/e2e/helpers/actions/deployApplication.ts
index 9cb19fd07..98058e4a1 100644
--- a/e2e/helpers/actions/deployApplication.ts
+++ b/e2e/helpers/actions/deployApplication.ts
@@ -31,7 +31,7 @@ export class DeployApplication implements Action<Application> {
   async rollback() {
     await this.model.owner.cliLogin();
     await exec(
-      `juju remove-application '${this.application.name}' -m '${this.model.name}' --force --no-prompt --no-wait`,
+      `juju remove-application '${this.application.name}' -m '${this.model.name}' --force --destroy-storage --no-prompt --no-wait`,
     );
   }
 
diff --git a/e2e/helpers/auth/Users.ts b/e2e/helpers/auth/Users.ts
index ce6b09b2a..5c57d9a22 100644
--- a/e2e/helpers/auth/Users.ts
+++ b/e2e/helpers/auth/Users.ts
@@ -73,11 +73,6 @@ export abstract class User {
    */
   abstract cliLogin(): Promise<void>;
 
-  /**
-   * Get the user's credential.
-   */
-  abstract getCredential(): Promise<string>;
-
   /**
    * Get the username for this user suitable for use on the CLI.
    */
diff --git a/e2e/helpers/auth/backends/Candid.ts b/e2e/helpers/auth/backends/Candid.ts
index f5e114fe7..135bd04b0 100644
--- a/e2e/helpers/auth/backends/Candid.ts
+++ b/e2e/helpers/auth/backends/Candid.ts
@@ -93,7 +93,7 @@ export class CandidUser extends LocalUser {
     await page.getByRole("link", { name: "Log in to the dashboard" }).click();
 
     const popup = await popupPromise;
-    await this.candidUiLogin(popup);
+    await this.candidUILogin(popup);
   }
 
   override async reloadDashboard(page: Page) {
@@ -127,7 +127,7 @@ export class CandidUser extends LocalUser {
     await page.goto(loginUrl);
 
     // Login with user credentials
-    await this.candidUiLogin(page);
+    await this.candidUILogin(page);
 
     // Wait for the original process to finish.
     await loginProc;
@@ -139,7 +139,7 @@ export class CandidUser extends LocalUser {
     return `${this.username}@external`;
   }
 
-  async candidUiLogin(page: Page) {
+  async candidUILogin(page: Page) {
     await page.getByRole("link", { name: "static" }).click();
     await page.getByRole("textbox", { name: "Username" }).fill(this.username);
     await page.getByRole("textbox", { name: "Password" }).fill(this.password);
diff --git a/e2e/helpers/auth/backends/Local.ts b/e2e/helpers/auth/backends/Local.ts
index 302c08dbb..258287402 100644
--- a/e2e/helpers/auth/backends/Local.ts
+++ b/e2e/helpers/auth/backends/Local.ts
@@ -1,5 +1,3 @@
-import fs from "node:fs/promises";
-
 import type { Page } from "@playwright/test";
 
 import type { User } from "..";
@@ -77,14 +75,4 @@ export class LocalUser implements User {
   public get displayName(): string {
     return this.dashboardUsername;
   }
-
-  public async getCredential(): Promise<string> {
-    const CREDENTIAL_TEMPLATE_PATH = "e2e/helpers/credentials-template.yaml";
-
-    const credential = (await fs.readFile(CREDENTIAL_TEMPLATE_PATH, "utf8"))
-      .replaceAll("{{username}}", this.cliUsername)
-      .replaceAll("{{password}}", this.password);
-
-    return credential;
-  }
 }
diff --git a/e2e/helpers/credentials-template.yaml b/e2e/helpers/credentials-template.yaml
deleted file mode 100644
index 5d835b159..000000000
--- a/e2e/helpers/credentials-template.yaml
+++ /dev/null
@@ -1,11 +0,0 @@
-credentials:
-  localhost:
-    default-region: localhost
-    {{username}}:
-      auth-type: interactive
-      trust-password: {{password}}
-  microk8s:
-    {{username}}:
-      auth-type: userpass
-      username: {{username}}
-      password: {{password}}
diff --git a/e2e/helpers/juju-cli.ts b/e2e/helpers/juju-cli.ts
index 92430dde0..d769f3558 100644
--- a/e2e/helpers/juju-cli.ts
+++ b/e2e/helpers/juju-cli.ts
@@ -1,5 +1,3 @@
-import fs from "node:fs/promises";
-
 import type { Browser } from "@playwright/test";
 
 import { JujuEnv } from "../fixtures/setup";
@@ -137,14 +135,13 @@ class BootstrapAction implements Action<User> {
     // Login as the user.
     await user.cliLogin();
 
-    // Add the user's credential to the controller.
-    const credential = await user.getCredential();
-    const tmpFilePath = `e2e/helpers/juju-cred-${Date.now()}.yaml`;
-    await fs.writeFile(tmpFilePath, credential, "utf8");
+    // Use the client credentials for this user (stored in
+    // ~/.local/share/juju/credentials.yaml). This allows authentication via the
+    // oauth token which is required in some scenarios (e.g. adding secrets when
+    // using microk8s).
     await exec(
-      `juju add-credential ${jujuCLI.provider} -f ${tmpFilePath} -c '${jujuCLI.controller}'`,
+      `juju update-credential ${jujuCLI.provider} ${jujuCLI.provider} -c '${jujuCLI.controller}'`,
     );
-    await fs.unlink(tmpFilePath);
   }
 
   /**
diff --git a/src/panels/ConfigPanel/ConfirmationDialog/index.ts b/src/panels/ConfigPanel/ConfirmationDialog/index.ts
index 20bf0713d..dcb77806f 100644
--- a/src/panels/ConfigPanel/ConfirmationDialog/index.ts
+++ b/src/panels/ConfigPanel/ConfirmationDialog/index.ts
@@ -1 +1,2 @@
 export { default } from "./ConfirmationDialog";
+export { Label as ConfirmationDialogLabel } from "./types";


Reviews:
user: login: huwshimi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T05:21:25Z
user: login: andogq
body: - If the `urls` PR is ready it'd be good to use it here. 

- The secrets test seems very large and quite complex. It'd be nice if it were multiple smaller tests, but we can worry about that at some later point when we add more!

- Feels very nice to get rid of the credentials template
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-15T06:48:15Z
user: login: huwshimi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T07:22:15Z
user: login: huwshimi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T07:23:56Z
user: login: huwshimi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T07:24:29Z
user: login: huwshimi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T07:42:53Z

Review comments:
diff_hunk: @@ -26,7 +26,7 @@ jobs:
         uses: charmed-kubernetes/actions-operator@main
         with:
           provider: "microk8s"
-          channel: 1.28-strict/stable
+          channel: 1.32-strict/stable
path: .github/workflows/e2e-juju-k8s-charm-local-auth.yml
user: login: huwshimi
body: Early on I tried upgrading this to see if it was an old bug, but while it didn't fix the issue, the test runs pass with this version so we might as well keep it.
created_at: 2025-05-15T04:52:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -77,14 +75,4 @@ export class LocalUser implements User {
   public get displayName(): string {
     return this.dashboardUsername;
   }
-
-  public async getCredential(): Promise<string> {
path: e2e/helpers/auth/backends/Local.ts
user: login: huwshimi
body: We don't need to use a template for this anymore as it's using the client credentials.
created_at: 2025-05-15T04:53:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -137,14 +135,13 @@ class BootstrapAction implements Action<User> {
     // Login as the user.
     await user.cliLogin();
 
-    // Add the user's credential to the controller.
-    const credential = await user.getCredential();
-    const tmpFilePath = `e2e/helpers/juju-cred-${Date.now()}.yaml`;
-    await fs.writeFile(tmpFilePath, credential, "utf8");
+    // Use the client credentials for this user (stored in
+    // ~/.local/share/juju/credentials.yaml). This allows authentication via the
path: e2e/helpers/juju-cli.ts
user: login: huwshimi
body: This file has the the oauth token stored in it.
created_at: 2025-05-15T04:55:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,42 +1,152 @@
 import { expect } from "@playwright/test";
 
+import { Label as AppLabel } from "pages/EntityDetails/App/types";
+import { Label as ModelTabsLabel } from "pages/EntityDetails/Model/ModelTabs/types";
+import {
+  Label as SecretsTableLabel,
+  TestId as SecretsTableTestId,
+} from "pages/EntityDetails/Model/Secrets/SecretsTable/types";
+import { Label as SecretsLabel } from "pages/EntityDetails/Model/Secrets/types";
+import { Label as ConfirmationDialogLabel } from "panels/ConfigPanel/ConfirmationDialog/types";
+import {
+  Label as ConfigPanelLabel,
+  TestId as ConfigPanelTestId,
+} from "panels/ConfigPanel/types";
+import {
+  Label as SecretFormPanelLabel,
+  TestId as SecretFormPanelTestId,
+} from "panels/SecretFormPanel/types";
+
 import { test } from "../fixtures/setup";
 import { ActionStack } from "../helpers/action";
-import { AddModel, GiveModelAccess } from "../helpers/actions";
+import {
+  AddModel,
+  DeployApplication,
+  GiveModelAccess,
+} from "../helpers/actions";
 import type { User } from "../helpers/auth";
-import type { Model } from "../helpers/objects";
 import { ModelPermission } from "../helpers/objects";
+import type { Application, Model } from "../helpers/objects";
+import { generateRandomName } from "../utils";
 
 test.describe("secrets", () => {
   let actions: ActionStack;
   let user: User;
   let nonAdmin: User;
   let model: Model;
+  let application: Application;
 
-  test.beforeAll(async ({ jujuCLI }) => {
+  test.beforeAll(async ({ jujuCLI, testOptions }) => {
+    test.setTimeout(300000);
path: e2e/base/secrets.spec.ts
user: login: andogq
body: A comment on why this increased timeout is needed would be nice 
created_at: 2025-05-15T06:38:06Z
author_association: MEMBER
diff_hunk: @@ -1,42 +1,152 @@
 import { expect } from "@playwright/test";
 
+import { Label as AppLabel } from "pages/EntityDetails/App/types";
+import { Label as ModelTabsLabel } from "pages/EntityDetails/Model/ModelTabs/types";
+import {
+  Label as SecretsTableLabel,
+  TestId as SecretsTableTestId,
+} from "pages/EntityDetails/Model/Secrets/SecretsTable/types";
+import { Label as SecretsLabel } from "pages/EntityDetails/Model/Secrets/types";
+import { Label as ConfirmationDialogLabel } from "panels/ConfigPanel/ConfirmationDialog/types";
+import {
+  Label as ConfigPanelLabel,
+  TestId as ConfigPanelTestId,
+} from "panels/ConfigPanel/types";
+import {
+  Label as SecretFormPanelLabel,
+  TestId as SecretFormPanelTestId,
+} from "panels/SecretFormPanel/types";
+
 import { test } from "../fixtures/setup";
 import { ActionStack } from "../helpers/action";
-import { AddModel, GiveModelAccess } from "../helpers/actions";
+import {
+  AddModel,
+  DeployApplication,
+  GiveModelAccess,
+} from "../helpers/actions";
 import type { User } from "../helpers/auth";
-import type { Model } from "../helpers/objects";
 import { ModelPermission } from "../helpers/objects";
+import type { Application, Model } from "../helpers/objects";
+import { generateRandomName } from "../utils";
 
 test.describe("secrets", () => {
   let actions: ActionStack;
   let user: User;
   let nonAdmin: User;
   let model: Model;
+  let application: Application;
 
-  test.beforeAll(async ({ jujuCLI }) => {
+  test.beforeAll(async ({ jujuCLI, testOptions }) => {
+    test.setTimeout(300000);
     actions = new ActionStack(jujuCLI);
 
     await actions.prepare((add) => {
       user = add(jujuCLI.createUser());
       nonAdmin = add(jujuCLI.createUser());
       model = add(new AddModel(user));
       add(new GiveModelAccess(model, nonAdmin, ModelPermission.READ));
+      application = add(new DeployApplication(model, testOptions.provider));
     });
   });
 
   test.afterAll(async () => {
     await actions.rollback();
   });
 
-  test("secrets do not display controls", async ({ page }) => {
+  test("Can create and use a secret", async ({ page }) => {
+    await user.dashboardLogin(page, "/models?enable-flag=rebac");
path: e2e/base/secrets.spec.ts
user: login: andogq
body: Should this be using the `urls` change from your previous PR?
created_at: 2025-05-15T06:40:12Z
author_association: MEMBER
diff_hunk: @@ -1,42 +1,152 @@
 import { expect } from "@playwright/test";
 
+import { Label as AppLabel } from "pages/EntityDetails/App/types";
+import { Label as ModelTabsLabel } from "pages/EntityDetails/Model/ModelTabs/types";
+import {
+  Label as SecretsTableLabel,
+  TestId as SecretsTableTestId,
+} from "pages/EntityDetails/Model/Secrets/SecretsTable/types";
+import { Label as SecretsLabel } from "pages/EntityDetails/Model/Secrets/types";
+import { Label as ConfirmationDialogLabel } from "panels/ConfigPanel/ConfirmationDialog/types";
+import {
+  Label as ConfigPanelLabel,
+  TestId as ConfigPanelTestId,
+} from "panels/ConfigPanel/types";
+import {
+  Label as SecretFormPanelLabel,
+  TestId as SecretFormPanelTestId,
+} from "panels/SecretFormPanel/types";
+
 import { test } from "../fixtures/setup";
 import { ActionStack } from "../helpers/action";
-import { AddModel, GiveModelAccess } from "../helpers/actions";
+import {
+  AddModel,
+  DeployApplication,
+  GiveModelAccess,
+} from "../helpers/actions";
 import type { User } from "../helpers/auth";
-import type { Model } from "../helpers/objects";
 import { ModelPermission } from "../helpers/objects";
+import type { Application, Model } from "../helpers/objects";
+import { generateRandomName } from "../utils";
 
 test.describe("secrets", () => {
   let actions: ActionStack;
   let user: User;
   let nonAdmin: User;
   let model: Model;
+  let application: Application;
 
-  test.beforeAll(async ({ jujuCLI }) => {
+  test.beforeAll(async ({ jujuCLI, testOptions }) => {
+    test.setTimeout(300000);
     actions = new ActionStack(jujuCLI);
 
     await actions.prepare((add) => {
       user = add(jujuCLI.createUser());
       nonAdmin = add(jujuCLI.createUser());
       model = add(new AddModel(user));
       add(new GiveModelAccess(model, nonAdmin, ModelPermission.READ));
+      application = add(new DeployApplication(model, testOptions.provider));
     });
   });
 
   test.afterAll(async () => {
     await actions.rollback();
   });
 
-  test("secrets do not display controls", async ({ page }) => {
+  test("Can create and use a secret", async ({ page }) => {
+    await user.dashboardLogin(page, "/models?enable-flag=rebac");
+
+    // Go to the secrets tab inside the model
+    await page.getByRole("link", { name: model.name }).click();
+    await page.getByRole("link", { name: ModelTabsLabel.SECRETS }).click();
+
+    await page.getByRole("button", { name: SecretsLabel.ADD }).click();
+    await expect(
+      page.getByTestId(SecretFormPanelTestId.PANEL),
+    ).toBeInViewport();
+
+    // Add a secret
+    const secret = generateRandomName("secret");
+    await page.getByRole("textbox", { name: "Label" }).fill(secret);
+    await page.getByRole("textbox", { name: "Key 1" }).fill("somekey");
+    await page.locator("textarea[aria-label='Value 1']").fill("somevalue");
+    await page
+      .getByTestId(SecretFormPanelTestId.PANEL)
+      .getByRole("button", { name: SecretFormPanelLabel.SUBMIT_ADD })
+      .click();
path: e2e/base/secrets.spec.ts
user: login: andogq
body: If this test was split up into discrete add/get/etc tests, this would be nice to be a new `Action`.

Certainly not required for this PR though. 
created_at: 2025-05-15T06:42:19Z
author_association: MEMBER
diff_hunk: @@ -77,14 +75,4 @@ export class LocalUser implements User {
   public get displayName(): string {
     return this.dashboardUsername;
   }
-
-  public async getCredential(): Promise<string> {
path: e2e/helpers/auth/backends/Local.ts
user: login: andogq
body: That's great! I didn't love this anyway so that works out!
created_at: 2025-05-15T06:45:51Z
author_association: MEMBER
diff_hunk: @@ -137,14 +135,13 @@ class BootstrapAction implements Action<User> {
     // Login as the user.
     await user.cliLogin();
 
-    // Add the user's credential to the controller.
-    const credential = await user.getCredential();
-    const tmpFilePath = `e2e/helpers/juju-cred-${Date.now()}.yaml`;
-    await fs.writeFile(tmpFilePath, credential, "utf8");
+    // Use the client credentials for this user (stored in
+    // ~/.local/share/juju/credentials.yaml). This allows authentication via the
path: e2e/helpers/juju-cli.ts
user: login: andogq
body: Just for my understanding, what puts this file here? Does it get generated with `juju login`? Or in some other way?
created_at: 2025-05-15T06:47:23Z
author_association: MEMBER
diff_hunk: @@ -137,14 +135,13 @@ class BootstrapAction implements Action<User> {
     // Login as the user.
     await user.cliLogin();
 
-    // Add the user's credential to the controller.
-    const credential = await user.getCredential();
-    const tmpFilePath = `e2e/helpers/juju-cred-${Date.now()}.yaml`;
-    await fs.writeFile(tmpFilePath, credential, "utf8");
+    // Use the client credentials for this user (stored in
+    // ~/.local/share/juju/credentials.yaml). This allows authentication via the
path: e2e/helpers/juju-cli.ts
user: login: huwshimi
body: It gets created as part of the bootstrap. You can also use `autoload-credentials` for Juju to find credentials from other clouds/providers and generate that file: https://documentation.ubuntu.com/juju/3.6/reference/credential/#file-credentials-yaml.
created_at: 2025-05-15T07:22:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,42 +1,152 @@
 import { expect } from "@playwright/test";
 
+import { Label as AppLabel } from "pages/EntityDetails/App/types";
+import { Label as ModelTabsLabel } from "pages/EntityDetails/Model/ModelTabs/types";
+import {
+  Label as SecretsTableLabel,
+  TestId as SecretsTableTestId,
+} from "pages/EntityDetails/Model/Secrets/SecretsTable/types";
+import { Label as SecretsLabel } from "pages/EntityDetails/Model/Secrets/types";
+import { Label as ConfirmationDialogLabel } from "panels/ConfigPanel/ConfirmationDialog/types";
+import {
+  Label as ConfigPanelLabel,
+  TestId as ConfigPanelTestId,
+} from "panels/ConfigPanel/types";
+import {
+  Label as SecretFormPanelLabel,
+  TestId as SecretFormPanelTestId,
+} from "panels/SecretFormPanel/types";
+
 import { test } from "../fixtures/setup";
 import { ActionStack } from "../helpers/action";
-import { AddModel, GiveModelAccess } from "../helpers/actions";
+import {
+  AddModel,
+  DeployApplication,
+  GiveModelAccess,
+} from "../helpers/actions";
 import type { User } from "../helpers/auth";
-import type { Model } from "../helpers/objects";
 import { ModelPermission } from "../helpers/objects";
+import type { Application, Model } from "../helpers/objects";
+import { generateRandomName } from "../utils";
 
 test.describe("secrets", () => {
   let actions: ActionStack;
   let user: User;
   let nonAdmin: User;
   let model: Model;
+  let application: Application;
 
-  test.beforeAll(async ({ jujuCLI }) => {
+  test.beforeAll(async ({ jujuCLI, testOptions }) => {
+    test.setTimeout(300000);
     actions = new ActionStack(jujuCLI);
 
     await actions.prepare((add) => {
       user = add(jujuCLI.createUser());
       nonAdmin = add(jujuCLI.createUser());
       model = add(new AddModel(user));
       add(new GiveModelAccess(model, nonAdmin, ModelPermission.READ));
+      application = add(new DeployApplication(model, testOptions.provider));
     });
   });
 
   test.afterAll(async () => {
     await actions.rollback();
   });
 
-  test("secrets do not display controls", async ({ page }) => {
+  test("Can create and use a secret", async ({ page }) => {
+    await user.dashboardLogin(page, "/models?enable-flag=rebac");
path: e2e/base/secrets.spec.ts
user: login: huwshimi
body: Thanks, I'll wait for that other one to land and then rebase this.
created_at: 2025-05-15T07:23:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,42 +1,152 @@
 import { expect } from "@playwright/test";
 
+import { Label as AppLabel } from "pages/EntityDetails/App/types";
+import { Label as ModelTabsLabel } from "pages/EntityDetails/Model/ModelTabs/types";
+import {
+  Label as SecretsTableLabel,
+  TestId as SecretsTableTestId,
+} from "pages/EntityDetails/Model/Secrets/SecretsTable/types";
+import { Label as SecretsLabel } from "pages/EntityDetails/Model/Secrets/types";
+import { Label as ConfirmationDialogLabel } from "panels/ConfigPanel/ConfirmationDialog/types";
+import {
+  Label as ConfigPanelLabel,
+  TestId as ConfigPanelTestId,
+} from "panels/ConfigPanel/types";
+import {
+  Label as SecretFormPanelLabel,
+  TestId as SecretFormPanelTestId,
+} from "panels/SecretFormPanel/types";
+
 import { test } from "../fixtures/setup";
 import { ActionStack } from "../helpers/action";
-import { AddModel, GiveModelAccess } from "../helpers/actions";
+import {
+  AddModel,
+  DeployApplication,
+  GiveModelAccess,
+} from "../helpers/actions";
 import type { User } from "../helpers/auth";
-import type { Model } from "../helpers/objects";
 import { ModelPermission } from "../helpers/objects";
+import type { Application, Model } from "../helpers/objects";
+import { generateRandomName } from "../utils";
 
 test.describe("secrets", () => {
   let actions: ActionStack;
   let user: User;
   let nonAdmin: User;
   let model: Model;
+  let application: Application;
 
-  test.beforeAll(async ({ jujuCLI }) => {
+  test.beforeAll(async ({ jujuCLI, testOptions }) => {
+    test.setTimeout(300000);
path: e2e/base/secrets.spec.ts
user: login: huwshimi
body: I'm not sure if we need that. We might, to give the application time to deploy, but I'll try without.
created_at: 2025-05-15T07:24:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,42 +1,152 @@
 import { expect } from "@playwright/test";
 
+import { Label as AppLabel } from "pages/EntityDetails/App/types";
+import { Label as ModelTabsLabel } from "pages/EntityDetails/Model/ModelTabs/types";
+import {
+  Label as SecretsTableLabel,
+  TestId as SecretsTableTestId,
+} from "pages/EntityDetails/Model/Secrets/SecretsTable/types";
+import { Label as SecretsLabel } from "pages/EntityDetails/Model/Secrets/types";
+import { Label as ConfirmationDialogLabel } from "panels/ConfigPanel/ConfirmationDialog/types";
+import {
+  Label as ConfigPanelLabel,
+  TestId as ConfigPanelTestId,
+} from "panels/ConfigPanel/types";
+import {
+  Label as SecretFormPanelLabel,
+  TestId as SecretFormPanelTestId,
+} from "panels/SecretFormPanel/types";
+
 import { test } from "../fixtures/setup";
 import { ActionStack } from "../helpers/action";
-import { AddModel, GiveModelAccess } from "../helpers/actions";
+import {
+  AddModel,
+  DeployApplication,
+  GiveModelAccess,
+} from "../helpers/actions";
 import type { User } from "../helpers/auth";
-import type { Model } from "../helpers/objects";
 import { ModelPermission } from "../helpers/objects";
+import type { Application, Model } from "../helpers/objects";
+import { generateRandomName } from "../utils";
 
 test.describe("secrets", () => {
   let actions: ActionStack;
   let user: User;
   let nonAdmin: User;
   let model: Model;
+  let application: Application;
 
-  test.beforeAll(async ({ jujuCLI }) => {
+  test.beforeAll(async ({ jujuCLI, testOptions }) => {
+    test.setTimeout(300000);
path: e2e/base/secrets.spec.ts
user: login: huwshimi
body: Looks like if failed without that timeout, so I'll add it back with a comment.
created_at: 2025-05-15T07:42:53Z
author_association: CONTRIBUTOR

PR comments:
user: login: webteam-app
created_at: 2025-05-14T02:49:52Z
body: ### [<img src='https://assets.ubuntu.com/v1/6baef514-ubuntu-circle-of-friends-large.svg' height=32 width=32> Demo</img>](https://juju-dashboard-1971.demos.haus)
### [<img src='https://assets.ubuntu.com/v1/e512b0e2-jenkins.svg' height=32 width=32> Jenkins </img>](https://jenkins.canonical.com/webteam/job/start-demo/2388)
### [<img src='https://assets.ubuntu.com/v1/7144ec6d-logo-jaas-icon.svg' height=32 width=32> demos.haus </img>](https://demos.haus)

author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-14T02:53:21Z
body: <h2>Coverage Report</h2>
<table> <thead> <tr> <th align="center">Status</th> <th align="left">Category</th> <th align="right">Percentage</th> <th align="right">Covered / Total</th> </tr> </thead> <tbody> <tr> <td align="center">🟢</td> <td align="left">Lines</td> <td align="right">98.02% (🎯 95%)</td> <td align="right">14562 / 14856</td> </tr> <tr> <td align="center">🟢</td> <td align="left">Statements</td> <td align="right">98.02% (🎯 95%)</td> <td align="right">14562 / 14856</td> </tr> <tr> <td align="center">🟢</td> <td align="left">Functions</td> <td align="right">98.05% (🎯 95%)</td> <td align="right">605 / 617</td> </tr> <tr> <td align="center">🟢</td> <td align="left">Branches</td> <td align="right">92.58% (🎯 90%)</td> <td align="right">3085 / 3332</td> </tr> </tbody> </table><details><summary>File Coverage</summary>No changed files found.</details>
<em>Generated in workflow <a href=https://github.com/canonical/juju-dashboard/actions/runs/15040077433>#1169</a> for commit <a href="https://github.com/canonical/juju-dashboard/commit/0b53d6b5a001e2e91e62fefab612c786da6aca6d">0b53d6b</a> by the <a href="https://github.com/davelosert/vitest-coverage-report-action">Vitest Coverage Report Action</a></em>

<!-- vitest-coverage-report-marker-root -->
author_association: NONE