Repository: canonical/juju-dashboard
Pull request #1967: chore(ci): add read-only tests
Author: login: huwshimi
Labels: name: Review: Code +1 (with changes)
name: e2e
description: Run E2E tests on this PR.
Created: 2025-05-13T05:07:57Z | Merged: 2025-05-15T02:11:04Z

## Done

Add tests that controls aren't displayed for read-only permissions.

## Details

https://warthogs.atlassian.net/browse/WD-20949

Passing run:
https://github.com/canonical/juju-dashboard/actions/runs/15010802849/job/42179029947?pr=1967



Diff:
diff --git a/e2e/base/actions.spec.ts b/e2e/base/actions.spec.ts
index 1b3495da4..17d8655eb 100644
--- a/e2e/base/actions.spec.ts
+++ b/e2e/base/actions.spec.ts
@@ -1,10 +1,17 @@
 import { expect } from "@playwright/test";
 
+import { TestId as AppTestId } from "pages/EntityDetails/App/types";
+
 import { test } from "../fixtures/setup";
 import { ActionStack } from "../helpers/action";
-import { AddModel, DeployApplication } from "../helpers/actions";
+import {
+  AddModel,
+  DeployApplication,
+  GiveModelAccess,
+} from "../helpers/actions";
 import type { User } from "../helpers/auth";
 import type { Model, Application } from "../helpers/objects";
+import { ModelPermission } from "../helpers/objects";
 
 test.describe("Actions", () => {
   let actions: ActionStack;
@@ -71,4 +78,24 @@ test.describe("Actions", () => {
         .and(page.locator("tr", { hasText: `1/${application.action}` })),
     ).toBeInViewport();
   });
+
+  test("actions controls aren't displayed to non-admins", async ({
+    jujuCLI,
+    page,
+  }) => {
+    await actions.prepare((add) => {
+      const nonAdmin = add(jujuCLI.createUser());
+      add(new GiveModelAccess(model, nonAdmin, ModelPermission.READ));
+    });
+    await user.dashboardLogin(
+      page,
+      `/models/${model.owner.dashboardUsername}/${model.name}/app/${application.name}?enable-flag=rebac`,
+    );
+    await expect(
+      page.getByTestId(AppTestId.UNITS_TABLE).getByRole("checkbox"),
+    ).not.toBeVisible();
+    await expect(
+      page.getByRole("button", { name: "Run action" }),
+    ).not.toBeVisible();
+  });
 });
diff --git a/e2e/base/configure.spec.ts b/e2e/base/configure.spec.ts
index 52defd331..6bdc8585a 100644
--- a/e2e/base/configure.spec.ts
+++ b/e2e/base/configure.spec.ts
@@ -2,14 +2,20 @@ import { expect } from "@playwright/test";
 
 import { test } from "../fixtures/setup";
 import { ActionStack } from "../helpers/action";
-import { AddModel, DeployApplication } from "../helpers/actions";
+import {
+  AddModel,
+  DeployApplication,
+  GiveModelAccess,
+} from "../helpers/actions";
 import type { User } from "../helpers/auth";
 import type { Model, Application } from "../helpers/objects";
+import { ModelPermission } from "../helpers/objects";
 
 test.describe("configure application", () => {
   let actions: ActionStack;
   let user: User;
   let model: Model;
+  let nonAdminUser: User;
   let application: Application;
 
   test.beforeAll(async ({ jujuCLI, testOptions }) => {
@@ -20,6 +26,8 @@ test.describe("configure application", () => {
       user = add(jujuCLI.createUser());
       model = add(new AddModel(user));
       application = add(new DeployApplication(model, testOptions.provider));
+      nonAdminUser = add(jujuCLI.createUser());
+      add(new GiveModelAccess(model, nonAdminUser, ModelPermission.READ));
     });
   });
 
@@ -60,4 +68,14 @@ test.describe("configure application", () => {
       page.getByTestId(application.config).getByRole("textbox"),
     ).toHaveValue(changed);
   });
+
+  test("application does not display configure button", async ({ page }) => {
+    await user.dashboardLogin(
+      page,
+      `/models/${model.owner.dashboardUsername}/${model.name}/app/${application.name}?enable-flag=rebac`,
+    );
+    await expect(
+      page.getByRole("button", { name: "Configure" }),
+    ).not.toBeVisible();
+  });
 });
diff --git a/e2e/base/models.spec.ts b/e2e/base/models.spec.ts
index 7772055aa..5d346f9d9 100644
--- a/e2e/base/models.spec.ts
+++ b/e2e/base/models.spec.ts
@@ -50,8 +50,31 @@ test.describe("Models", () => {
   test("Cannot access model without permission", async ({ page }) => {
     await user2.dashboardLogin(
       page,
-      `/models/${user1.dashboardUsername}/${user1Model.name}`,
+      `/models/${user1.cliUsername}/${user1Model.name}`,
     );
     await expect(page.getByText("Model not found")).toBeVisible();
   });
+
+  test("model list does not display access button to non-admins", async ({
+    page,
+  }) => {
+    await user2.dashboardLogin(page, "/models?enable-flag=rebac");
+    // The access button only appears on hover.
+    await page.getByRole("link", { name: sharedModel.name }).hover();
+    await expect(
+      page.getByRole("button", { name: "Access" }),
+    ).not.toBeVisible();
+  });
+
+  test("model details does not display access button to non-admins", async ({
+    page,
+  }) => {
+    await user2.dashboardLogin(
+      page,
+      `/models/${user1.cliUsername}/${user1Model.name}?enable-flag=rebac`,
+    );
+    await expect(
+      page.getByRole("button", { name: "Manage access" }),
+    ).not.toBeVisible();
+  });
 });
diff --git a/e2e/base/secrets.spec.ts b/e2e/base/secrets.spec.ts
new file mode 100644
index 000000000..aa0dc6f41
--- /dev/null
+++ b/e2e/base/secrets.spec.ts
@@ -0,0 +1,42 @@
+import { expect } from "@playwright/test";
+
+import { test } from "../fixtures/setup";
+import { ActionStack } from "../helpers/action";
+import { AddModel, GiveModelAccess } from "../helpers/actions";
+import type { User } from "../helpers/auth";
+import type { Model } from "../helpers/objects";
+import { ModelPermission } from "../helpers/objects";
+
+test.describe("secrets", () => {
+  let actions: ActionStack;
+  let user: User;
+  let nonAdmin: User;
+  let model: Model;
+
+  test.beforeAll(async ({ jujuCLI }) => {
+    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));
+    });
+  });
+
+  test.afterAll(async () => {
+    await actions.rollback();
+  });
+
+  test("secrets do not display controls", async ({ page }) => {
+    await nonAdmin.dashboardLogin(
+      page,
+      `/models/${model.owner.cliUsername}/${model.name}?activeView=secrets&enable-flag=rebac`,
+    );
+    // Go to the application inside the model:
+    await page.getByRole("link", { name: model.name }).hover();
+    await expect(
+      page.getByRole("button", { name: "Access" }),
+    ).not.toBeVisible();
+  });
+});
diff --git a/e2e/helpers/actions/utils/give-access.ts b/e2e/helpers/actions/utils/give-access.ts
index 292b62eda..7cb8c26b8 100644
--- a/e2e/helpers/actions/utils/give-access.ts
+++ b/e2e/helpers/actions/utils/give-access.ts
@@ -26,9 +26,11 @@ export class GiveAccess<Entity extends Model | Controller>
     jimmCommand: string,
   ) {
     if (jujuCLI.jujuEnv == JujuEnv.JUJU) {
+      const entityName =
+        this.tag === "controller" ? "" : `'${this.entityName}'`;
       await this.entity.owner.cliLogin();
       await exec(
-        `juju ${jujuCommand} '${this.user.cliUsername}' '${this.access}' '${this.entity.name}'`,
+        `juju ${jujuCommand} '${this.user.cliUsername}' '${this.access}' '${entityName}'`,
       );
     } else {
       await jujuCLI.loginIdentityCLIAdmin();
diff --git a/e2e/jimm/audit-logs.spec.ts b/e2e/jimm/audit-logs.spec.ts
index 18f57704d..65cc575c1 100644
--- a/e2e/jimm/audit-logs.spec.ts
+++ b/e2e/jimm/audit-logs.spec.ts
@@ -1,21 +1,29 @@
 import { expect } from "@playwright/test";
 
+import { Label as PageNotFoundLabel } from "pages/PageNotFound/types";
+
 import { test } from "../fixtures/setup";
 import { ActionStack } from "../helpers/action";
-import { GiveControllerAccess, AddModel } from "../helpers/actions";
+import {
+  GiveControllerAccess,
+  AddModel,
+  GiveModelAccess,
+} from "../helpers/actions";
 import type { User } from "../helpers/auth";
 import type { Model } from "../helpers/objects";
-import { ControllerPermission } from "../helpers/objects";
+import { ControllerPermission, ModelPermission } from "../helpers/objects";
 
 test.describe("audit logs", () => {
   let actions: ActionStack;
   let user: User;
+  let nonAdminUser: User;
   let model: Model;
 
   test.beforeAll(async ({ jujuCLI }) => {
     actions = new ActionStack(jujuCLI);
     await actions.prepare((add) => {
       user = add(jujuCLI.createUser());
+      nonAdminUser = add(jujuCLI.createUser());
       model = add(new AddModel(user));
       add(
         new GiveControllerAccess(
@@ -24,6 +32,7 @@ test.describe("audit logs", () => {
           ControllerPermission.SUPERUSER,
         ),
       );
+      add(new GiveModelAccess(model, nonAdminUser, ModelPermission.READ));
     });
   });
 
@@ -60,4 +69,40 @@ test.describe("audit logs", () => {
         .first(),
     ).toBeVisible();
   });
+
+  test("all logs link is not displayed for non-admins", async ({ page }) => {
+    await nonAdminUser.dashboardLogin(page, "/models?enable-flag=rebac");
+    await expect(
+      page.getByRole("banner").getByRole("link", { name: "Logs" }),
+    ).not.toBeVisible();
+  });
+
+  test("all logs page can't be accessed by non-admins", async ({ page }) => {
+    await nonAdminUser.dashboardLogin(page, "/logs?enable-flag=rebac");
+    await expect(
+      page.getByRole("heading", { name: "Audit logs" }),
+    ).not.toBeVisible();
+    await expect(
+      page.getByRole("heading", {
+        name: PageNotFoundLabel.NOT_FOUND,
+      }),
+    ).toBeVisible();
+  });
+
+  test("model logs link is not displayed for non-admins", async ({ page }) => {
+    await nonAdminUser.dashboardLogin(page, "/models?enable-flag=rebac");
+    await expect(
+      page.getByRole("banner").getByRole("link", { name: "Logs" }),
+    ).not.toBeVisible();
+  });
+
+  test("model logs tab can't be accessed by non-admins", async ({ page }) => {
+    await nonAdminUser.dashboardLogin(
+      page,
+      `/models/${model.owner.dashboardUsername}/${model.name}?activeView=logs&tableView=audit-logs&enable-flag=rebac`,
+    );
+    await expect(
+      page.getByRole("tab", { name: "Audit logs" }),
+    ).not.toBeVisible();
+  });
 });
diff --git a/e2e/jimm/rebac.spec.ts b/e2e/jimm/rebac.spec.ts
index 173a90d94..b89da8dce 100644
--- a/e2e/jimm/rebac.spec.ts
+++ b/e2e/jimm/rebac.spec.ts
@@ -3,13 +3,18 @@ import { expect } from "@playwright/test";
 import { test } from "../fixtures/setup";
 import { ActionStack } from "../helpers/action";
 import { GiveControllerAccess } from "../helpers/actions";
+import type { User } from "../helpers/auth";
 import { ControllerPermission } from "../helpers/objects";
 
 test.describe("ReBAC Admin", () => {
   let actions: ActionStack;
+  let nonAdminUser: User;
 
-  test.beforeAll(({ jujuCLI }) => {
+  test.beforeAll(async ({ jujuCLI }) => {
     actions = new ActionStack(jujuCLI);
+    await actions.prepare((add) => {
+      nonAdminUser = add(jujuCLI.createUser());
+    });
   });
 
   test.afterAll(async () => {
@@ -35,4 +40,26 @@ test.describe("ReBAC Admin", () => {
       page.locator("td", { hasText: jujuCLI.identityAdmin.dashboardUsername }),
     ).toBeVisible();
   });
+
+  test("link is not displayed for non-admins", async ({ page }) => {
+    await nonAdminUser.dashboardLogin(page, "/models?enable-flag=rebac");
+    await expect(
+      page.getByRole("banner").getByRole("link", { name: "Permissions" }),
+    ).not.toBeVisible();
+  });
+
+  test("can't be accessed by non-admins", async ({ page }) => {
+    await nonAdminUser.dashboardLogin(
+      page,
+      "/permissions/users?enable-flag=rebac",
+    );
+    await expect(
+      page.getByRole("heading", {
+        name: "Hmm, we can't seem to find that page...",
+      }),
+    ).toBeVisible();
+    await expect(
+      page.getByRole("heading", { name: "Users" }),
+    ).not.toBeVisible();
+  });
 });


Reviews:
user: login: andogq
body: - Look out for `urls` changes from #1968 
- Few minor comments
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-14T15:35:15Z
user: login: huwshimi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T01:11:53Z
user: login: huwshimi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T01:12:41Z
user: login: huwshimi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T01:13:32Z
user: login: huwshimi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T01:14:51Z

Review comments:
diff_hunk: @@ -71,4 +76,22 @@ test.describe("Actions", () => {
         .and(page.locator("tr", { hasText: `1/${application.action}` })),
     ).toBeInViewport();
   });
+
+  test("actions controls aren't displayed to non-admins", async ({
+    jujuCLI,
+    page,
+  }) => {
+    await actions.prepare((add) => {
+      const nonAdmin = add(jujuCLI.createUser());
+      add(new GiveModelAccess(model, nonAdmin, ModelPermission.READ));
+    });
+    await user.dashboardLogin(
+      page,
+      `/models/${model.owner.dashboardUsername}/${model.name}/app/${application.name}?enable-flag=rebac`,
+    );
+    await expect(page.getByRole("checkbox")).not.toBeVisible();
path: e2e/base/actions.spec.ts
user: login: andogq
body: I'm assuming this checkbox aligns with the nonAdmin's user row in the access? Would it be possible to select the correct row based on `nonAdmin`'s name, and then pull the checkbox from that row? Just incase we add another checkbox somewhere on the page.

@Ninfa-Jeon seems to have done something similar here: https://github.com/canonical/juju-dashboard/pull/1962/files#diff-dd76c5846f397d2a289aff32f83ee0828b1aff35f93274d32097ac5c99a902a1R38-R40
created_at: 2025-05-14T15:26:57Z
author_association: MEMBER
diff_hunk: @@ -60,4 +68,14 @@ test.describe("configure application", () => {
       page.getByTestId(application.config).getByRole("textbox"),
     ).toHaveValue(changed);
   });
+
+  test("application does not display configure button", async ({ page }) => {
+    await user.dashboardLogin(
+      page,
+      `/models/${model.owner.dashboardUsername}/${model.name}/app/${application.name}?enable-flag=rebac`,
path: e2e/base/configure.spec.ts
user: login: andogq
body: Make sure this is corrected to use `urls` from #1968 
created_at: 2025-05-14T15:30:19Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,43 @@
+import { expect } from "@playwright/test";
+
+import { test } from "../fixtures/setup";
+import { ActionStack } from "../helpers/action";
+import { AddModel, GiveModelAccess } from "../helpers/actions";
+import type { User } from "../helpers/auth";
+import type { Model } from "../helpers/objects";
+import { ModelPermission } from "../helpers/objects";
+
+test.describe("secrets", () => {
+  let actions: ActionStack;
+  let user: User;
+  let nonAdmin: User;
+  let model: Model;
+
+  test.beforeAll(async ({ jujuCLI }) => {
+    test.setTimeout(300000);
path: e2e/base/secrets.spec.ts
user: login: andogq
body: Might be nice to have a comment stating why we need the increased timeout?

created_at: 2025-05-14T15:31:52Z
author_association: MEMBER
diff_hunk: @@ -26,9 +26,11 @@ export class GiveAccess<Entity extends Model | Controller>
     jimmCommand: string,
   ) {
     if (jujuCLI.jujuEnv == JujuEnv.JUJU) {
+      const entityName =
+        this.tag === "controller" ? "" : `'${this.entityName}'`;
       await this.entity.owner.cliLogin();
       await exec(
-        `juju ${jujuCommand} '${this.user.cliUsername}' '${this.access}' '${this.entity.name}'`,
+        `juju ${jujuCommand} '${this.user.cliUsername}' '${this.access}' ${entityName}`,
path: e2e/helpers/actions/utils/give-access.ts
user: login: andogq
body: ```suggestion
        `juju ${jujuCommand} '${this.user.cliUsername}' '${this.access}' '${entityName}'`,
```
created_at: 2025-05-14T15:34:02Z
author_association: MEMBER
diff_hunk: @@ -60,4 +67,40 @@ test.describe("audit logs", () => {
         .first(),
     ).toBeVisible();
   });
+
+  test("all logs link is not displayed for non-admins", async ({ page }) => {
+    await nonAdminUser.dashboardLogin(page, "/models?enable-flag=rebac");
+    await expect(
+      page.getByRole("banner").getByRole("link", { name: "Logs" }),
+    ).not.toBeVisible();
+  });
+
+  test("all logs page can't be accessed by non-admins", async ({ page }) => {
+    await nonAdminUser.dashboardLogin(page, "/logs?enable-flag=rebac");
+    await expect(
+      page.getByRole("heading", { name: "Audit logs" }),
+    ).not.toBeVisible();
+    await expect(
+      page.getByRole("heading", {
+        name: "Hmm, we can't seem to find that page...",
path: e2e/jimm/audit-logs.spec.ts
user: login: andogq
body: Should this be an enum label?
created_at: 2025-05-14T15:34:52Z
author_association: MEMBER
diff_hunk: @@ -71,4 +76,22 @@ test.describe("Actions", () => {
         .and(page.locator("tr", { hasText: `1/${application.action}` })),
     ).toBeInViewport();
   });
+
+  test("actions controls aren't displayed to non-admins", async ({
+    jujuCLI,
+    page,
+  }) => {
+    await actions.prepare((add) => {
+      const nonAdmin = add(jujuCLI.createUser());
+      add(new GiveModelAccess(model, nonAdmin, ModelPermission.READ));
+    });
+    await user.dashboardLogin(
+      page,
+      `/models/${model.owner.dashboardUsername}/${model.name}/app/${application.name}?enable-flag=rebac`,
+    );
+    await expect(page.getByRole("checkbox")).not.toBeVisible();
path: e2e/base/actions.spec.ts
user: login: huwshimi
body: Thanks, there are checkboxes next to the application, and one in the table header as well and we don't want any of them to appear. I've updated this to check for checkboxes inside the units table.
created_at: 2025-05-15T01:11:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,43 @@
+import { expect } from "@playwright/test";
+
+import { test } from "../fixtures/setup";
+import { ActionStack } from "../helpers/action";
+import { AddModel, GiveModelAccess } from "../helpers/actions";
+import type { User } from "../helpers/auth";
+import type { Model } from "../helpers/objects";
+import { ModelPermission } from "../helpers/objects";
+
+test.describe("secrets", () => {
+  let actions: ActionStack;
+  let user: User;
+  let nonAdmin: User;
+  let model: Model;
+
+  test.beforeAll(async ({ jujuCLI }) => {
+    test.setTimeout(300000);
path: e2e/base/secrets.spec.ts
user: login: huwshimi
body: Thanks, I don't actually think that's necessary so I've removed it.
created_at: 2025-05-15T01:12:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -26,9 +26,11 @@ export class GiveAccess<Entity extends Model | Controller>
     jimmCommand: string,
   ) {
     if (jujuCLI.jujuEnv == JujuEnv.JUJU) {
+      const entityName =
+        this.tag === "controller" ? "" : `'${this.entityName}'`;
       await this.entity.owner.cliLogin();
       await exec(
-        `juju ${jujuCommand} '${this.user.cliUsername}' '${this.access}' '${this.entity.name}'`,
+        `juju ${jujuCommand} '${this.user.cliUsername}' '${this.access}' ${entityName}`,
path: e2e/helpers/actions/utils/give-access.ts
user: login: huwshimi
body: Oops thanks I've fixed that. I added some changes here and then removed them.
created_at: 2025-05-15T01:13:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -60,4 +67,40 @@ test.describe("audit logs", () => {
         .first(),
     ).toBeVisible();
   });
+
+  test("all logs link is not displayed for non-admins", async ({ page }) => {
+    await nonAdminUser.dashboardLogin(page, "/models?enable-flag=rebac");
+    await expect(
+      page.getByRole("banner").getByRole("link", { name: "Logs" }),
+    ).not.toBeVisible();
+  });
+
+  test("all logs page can't be accessed by non-admins", async ({ page }) => {
+    await nonAdminUser.dashboardLogin(page, "/logs?enable-flag=rebac");
+    await expect(
+      page.getByRole("heading", { name: "Audit logs" }),
+    ).not.toBeVisible();
+    await expect(
+      page.getByRole("heading", {
+        name: "Hmm, we can't seem to find that page...",
path: e2e/jimm/audit-logs.spec.ts
user: login: huwshimi
body: Thanks, done.
created_at: 2025-05-15T01:14:50Z
author_association: CONTRIBUTOR

PR comments:
user: login: webteam-app
created_at: 2025-05-13T05:08:00Z
body: ### [<img src='https://assets.ubuntu.com/v1/6baef514-ubuntu-circle-of-friends-large.svg' height=32 width=32> Demo</img>](https://juju-dashboard-1967.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/2256)
### [<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-13T05:11:28Z
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">14565 / 14859</td> </tr> <tr> <td align="center">🟢</td> <td align="left">Statements</td> <td align="right">98.02% (🎯 95%)</td> <td align="right">14565 / 14859</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.59% (🎯 90%)</td> <td align="right">3087 / 3334</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/15034453215>#1144</a> for commit <a href="https://github.com/canonical/juju-dashboard/commit/308bdca2bb2c8c40d9b979e60e207e75786097d4">308bdca</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