Files
SnapOtter/tests/e2e/gui-settings-expanded.spec.ts
T
SnapOtterandGitHub d10d0f544f fix: release QA hardening across processing, media, security, and CI gates (#649)
A release-readiness QA pass over the whole product. The commits split into
defects a user would hit and gates that were reporting green while measuring
nothing.

## Fixes that change behaviour

Rate limiting was bypassable on every install: TRUST_PROXY defaulted to true, so
request.ip came from a client-set header and a forged X-Forwarded-For got past
the login limiter. The default is now a private-network trust list.

A transient Postgres outage stranded in-flight jobs, leaving finished output on
disk with no row pointing at it. A reconciler now resolves those rows and adopts
the bytes rather than dropping the work.

A Redis connection that moved to a new address wedged every read-blocked
consumer, so completions stopped signalling while health still answered 200.
Socket timeouts plus subscriber pings recover it.

Installing more than one AI bundle left the shared venv multi-versioned and
silently broke three tools. The installer now reconciles distributions to one
version each.

Converting an image to JXL at quality 1 through 4 returned a 500, because
libjxl 0.7 rejects the distance those values compute. The quality is floored at
what the encoder honours. A missing ffmpeg was also reported to the user as a
corrupt upload; it now says the engine is unavailable.

RAW uploads reached an unpatched LibRaw on arm64, so it is built from source at
0.22.2, and the release scan was split so it can fail on an unfixed critical
instead of hiding it behind ignore-unfixed.

## Gates that could not fail

Two mutation lanes ran zero mutants because Stryker crawled the gitignored docs
build; coverage discarded its whole report on any failing test; the lint gate
skipped root tests, scripts, and two workspaces; and several generated matrices
counted a host missing ffmpeg as a passing tool. Each now measures what it
claims.

Full evidence and the outstanding release items are tracked locally and are not
part of this branch.
2026-07-27 15:37:30 +08:00

1556 lines
58 KiB
TypeScript

import { test as base, expect } from "@playwright/test";
import { authFile } from "../../playwright.config";
import { login, openSettings, test } from "./helpers";
const API = process.env.API_URL || "http://localhost:13490";
/** Auth + JSON content-type (POST, PUT). */
function authJson(token: string): Record<string, string> {
return { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
}
/** Auth header only (GET, DELETE). */
function authOnly(token: string): Record<string, string> {
return { Authorization: `Bearer ${token}` };
}
async function getAdminToken(): Promise<string> {
const res = await fetch(`${API}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "admin", password: "admin" }),
});
// Fail here, not three calls later. Without this an admin login that was
// refused returns undefined and every later request sends "Bearer undefined",
// which surfaces as a 401 on whatever the test happened to do next.
if (!res.ok) {
throw new Error(
`Admin login failed with ${res.status}; the admin password may not have been restored by an earlier spec`,
);
}
const data = (await res.json()) as { token?: string };
if (!data.token) throw new Error("Admin login returned no token");
return data.token;
}
/** Delete all non-admin test users by prefix. */
async function cleanupUsersByPrefix(adminToken: string, prefix: string): Promise<void> {
const listRes = await fetch(`${API}/api/auth/users`, {
headers: authOnly(adminToken),
});
if (!listRes.ok) return;
const { users } = await listRes.json();
for (const u of users) {
if (u.username.startsWith(prefix)) {
await fetch(`${API}/api/auth/users/${u.id}`, {
method: "DELETE",
headers: authOnly(adminToken),
});
}
}
}
/** Delete test teams by prefix. */
async function cleanupTeamsByPrefix(adminToken: string, prefix: string): Promise<void> {
const listRes = await fetch(`${API}/api/v1/teams`, {
headers: authOnly(adminToken),
});
if (!listRes.ok) return;
const { teams } = await listRes.json();
for (const t of teams) {
if (t.name.startsWith(prefix)) {
await fetch(`${API}/api/v1/teams/${t.id}`, {
method: "DELETE",
headers: authOnly(adminToken),
});
}
}
}
/** Delete custom roles by prefix. */
async function cleanupRolesByPrefix(adminToken: string, prefix: string): Promise<void> {
const listRes = await fetch(`${API}/api/v1/roles`, {
headers: authOnly(adminToken),
});
if (!listRes.ok) return;
const { roles } = await listRes.json();
for (const r of roles) {
if (r.name.startsWith(prefix)) {
await fetch(`${API}/api/v1/roles/${r.id}`, {
method: "DELETE",
headers: authOnly(adminToken),
});
}
}
}
/**
* Create a user with a given role and clear mustChangePassword
* so the browser login redirects to "/" instead of "/change-password".
*/
async function createReadyUser(
adminToken: string,
username: string,
password: string,
role: string,
): Promise<void> {
const createRes = await fetch(`${API}/api/auth/register`, {
method: "POST",
headers: authJson(adminToken),
body: JSON.stringify({ username, password, role }),
});
if (createRes.status !== 201 && createRes.status !== 409) {
throw new Error(`Failed to create user ${username}: ${createRes.status}`);
}
// Login to get token, then change password to clear mustChangePassword
const loginRes = await fetch(`${API}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!loginRes.ok) throw new Error(`Login failed for ${username}: ${loginRes.status}`);
const loginData = await loginRes.json();
await fetch(`${API}/api/auth/change-password`, {
method: "POST",
headers: authJson(loginData.token),
body: JSON.stringify({ currentPassword: password, newPassword: password }),
});
}
/** Delete a user by username if it exists. */
async function deleteUser(adminToken: string, username: string): Promise<void> {
const listRes = await fetch(`${API}/api/auth/users`, {
headers: authOnly(adminToken),
});
if (!listRes.ok) return;
const { users } = await listRes.json();
const found = users.find((u: { username: string }) => u.username === username);
if (found) {
await fetch(`${API}/api/auth/users/${found.id}`, {
method: "DELETE",
headers: authOnly(adminToken),
});
}
}
const UID = Date.now().toString(36);
// ===========================================================================
// SETTINGS DIALOG -- Dialog Interactions (expanded)
// ===========================================================================
test.describe("Settings Dialog - Dialog state management", () => {
test("dialog content scrolls independently of the page", async ({ loggedInPage: page }) => {
await openSettings(page);
// The dialog's content pane has overflow-y-auto. Scope to the dialog: the
// page's <main> also carries .flex-1.overflow-y-auto.
const contentPane = page.getByRole("dialog").locator(".flex-1.overflow-y-auto");
await expect(contentPane.first()).toBeVisible();
// Navigate to a section with lots of content (Tools)
await page.getByRole("button", { name: /tools/i }).click();
await expect(page.locator("h3").filter({ hasText: "Tools" }).first()).toBeVisible();
});
test("rapidly switching tabs does not cause rendering errors", async ({ loggedInPage: page }) => {
await openSettings(page);
// Quickly click through several tabs
const tabs = ["Security", "About", "General", "Tools", "API Keys", "About"];
for (const tab of tabs) {
await page.getByRole("button", { name: new RegExp(tab, "i") }).click();
}
// Final tab should render correctly
await expect(page.locator("h3").filter({ hasText: "About" })).toBeVisible();
});
test("settings dialog renders with role=dialog and aria-modal", async ({
loggedInPage: page,
}) => {
await openSettings(page);
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await expect(dialog).toHaveAttribute("aria-modal", "true");
});
test("backdrop click does not interfere with dialog controls", async ({ loggedInPage: page }) => {
await openSettings(page);
// Click a button inside the dialog -- should not close it
await page.getByRole("button", { name: /about/i }).click();
await expect(page.locator("h3").filter({ hasText: "About" })).toBeVisible();
// Dialog should still be open
await expect(page.locator("h2").filter({ hasText: "Settings" })).toBeVisible();
});
});
// ===========================================================================
// GENERAL TAB -- expanded tests
// ===========================================================================
test.describe("Settings General Tab - User info details", () => {
test("displays role in capitalized format below username", async ({ loggedInPage: page }) => {
await openSettings(page);
// The role text is displayed with capitalize class
const roleEl = page.locator("p.capitalize").first();
await expect(roleEl).toBeVisible();
const roleText = await roleEl.textContent();
expect(roleText?.toLowerCase()).toContain("admin");
});
test("General tab has user preferences description", async ({ loggedInPage: page }) => {
await openSettings(page);
// Description text should be present
await expect(page.getByText("User preferences and display settings.")).toBeVisible();
});
test("Save Settings button is disabled while saving", async ({ loggedInPage: page }) => {
await openSettings(page);
const saveBtn = page.getByRole("button", { name: /save settings/i });
await expect(saveBtn).toBeVisible();
await expect(saveBtn).toBeEnabled();
// Click save and check the button shows loading state
await saveBtn.click();
// Eventually the save completes and message appears
await expect(page.getByText("Settings saved.")).toBeVisible({ timeout: 5_000 });
});
test("General tab shows Language (locale) dropdown", async ({ loggedInPage: page }) => {
await openSettings(page);
// Exact match: "Language" also appears inside the row description
// ("Language for the interface"), which would trip strict mode.
await expect(page.getByText("Language", { exact: true })).toBeVisible();
// The locale select has the user's current locale as its value
const localeSelect = page.locator("select").filter({ has: page.locator("option[value='en']") });
await expect(localeSelect).toBeVisible();
});
});
// ===========================================================================
// SYSTEM SETTINGS TAB -- expanded validation
// ===========================================================================
test.describe("Settings System Settings Tab - Additional coverage", () => {
test("shows description 'Server-side configuration and limits.'", async ({
loggedInPage: page,
}) => {
await openSettings(page);
await page.getByRole("button", { name: /system settings/i }).click();
await expect(page.getByText("Server-side configuration and limits.")).toBeVisible();
});
test("File Upload Limit has min=1 attribute", async ({ loggedInPage: page }) => {
await openSettings(page);
await page.getByRole("button", { name: /system settings/i }).click();
await expect(page.getByText("File Upload Limit (MB)")).toBeVisible();
const uploadInput = page.locator("input[type='number']").first();
await expect(uploadInput).toHaveAttribute("min", "1");
});
test("Login Attempt Limit has min=1 and max=100 attributes", async ({ loggedInPage: page }) => {
await openSettings(page);
await page.getByRole("button", { name: /system settings/i }).click();
await expect(page.getByText("Login Attempt Limit")).toBeVisible();
const loginInput = page.locator("input[type='number']").nth(1);
await expect(loginInput).toHaveAttribute("min", "1");
await expect(loginInput).toHaveAttribute("max", "100");
});
test("System theme dropdown has light, dark, and system options", async ({
loggedInPage: page,
}) => {
await openSettings(page);
await page.getByRole("button", { name: /system settings/i }).click();
const themeSelect = page
.locator("select")
.filter({ has: page.locator("option[value='dark']") });
// The System section loads its settings asynchronously; wait for the select
// to render before reading its options.
await expect(themeSelect).toBeVisible({ timeout: 10_000 });
const options = await themeSelect.locator("option").allTextContents();
expect(options).toContain("Light");
expect(options).toContain("Dark");
expect(options).toContain("System");
});
test("Language dropdown contains English option", async ({ loggedInPage: page }) => {
await openSettings(page);
await page.getByRole("button", { name: /system settings/i }).click();
const langSelect = page.locator("select").filter({ has: page.locator("option[value='en']") });
await expect(langSelect).toBeVisible();
const enOption = langSelect.locator("option[value='en']");
await expect(enOption).toBeAttached();
});
});
// ===========================================================================
// SECURITY TAB -- expanded password validation flows
// ===========================================================================
test.describe("Settings Security Tab - Extended password flows", () => {
test("empty form submission is prevented by browser required validation", async ({
loggedInPage: page,
}) => {
await openSettings(page);
await page.getByRole("button", { name: /security/i }).click();
// All password inputs should have the required attribute
const currentPw = page.getByPlaceholder("Current Password");
const newPw = page.getByPlaceholder("New Password").first();
const confirmPw = page.getByPlaceholder("Confirm New Password");
await expect(currentPw).toHaveAttribute("required", "");
await expect(newPw).toHaveAttribute("required", "");
await expect(confirmPw).toHaveAttribute("required", "");
});
test("multiple password visibility toggles work independently", async ({
loggedInPage: page,
}) => {
await openSettings(page);
await page.getByRole("button", { name: /security/i }).click();
const currentPwInput = page.getByPlaceholder("Current Password");
const newPwInput = page.getByPlaceholder("New Password").first();
// Both start as password type
await expect(currentPwInput).toHaveAttribute("type", "password");
await expect(newPwInput).toHaveAttribute("type", "password");
// Toggle the first one
const toggleButtons = page.locator("form button[type='button']");
await toggleButtons.first().click();
// Only the first should change
await expect(currentPwInput).toHaveAttribute("type", "text");
await expect(newPwInput).toHaveAttribute("type", "password");
});
test("security section shows Change Password sub-heading", async ({ loggedInPage: page }) => {
await openSettings(page);
await page.getByRole("button", { name: /security/i }).click();
// The form has a sub-heading for the change password section
await expect(page.getByText("Change Password").first()).toBeVisible();
});
test("changing a password to the same value succeeds", async ({ loggedInPage: page }) => {
// Exercised on a throwaway user: the default admin password ("admin", 5
// chars) cannot satisfy the 8-char policy, so a same-value change can never
// be submitted for it.
const uid = Date.now().toString(36);
const username = `samepw-${uid}`;
const password = "Samepass123";
const adminToken = await page.evaluate(() => localStorage.getItem("snapotter-token"));
const reg = await page.request.post("/api/auth/register", {
headers: { authorization: `Bearer ${adminToken}` },
data: { username, password, role: "user" },
});
expect(reg.ok()).toBeTruthy();
try {
const userLogin = await page.request.post("/api/auth/login", {
data: { username, password },
});
const { token } = await userLogin.json();
const change = await page.request.post("/api/auth/change-password", {
headers: { authorization: `Bearer ${token}` },
data: { currentPassword: password, newPassword: password },
});
expect(change.ok()).toBeTruthy();
} finally {
const list = await page.request.get("/api/auth/users", {
headers: { authorization: `Bearer ${adminToken}` },
});
if (list.ok()) {
const { users } = await list.json();
const u = users.find((x: { username: string; id: string }) => x.username === username);
if (u) {
await page.request.delete(`/api/auth/users/${u.id}`, {
headers: { authorization: `Bearer ${adminToken}` },
});
}
}
}
});
});
// ===========================================================================
// ABOUT TAB -- expanded tests
// ===========================================================================
test.describe("Settings About Tab - Extended", () => {
test("shows AGPLv3 license information", async ({ loggedInPage: page }) => {
await openSettings(page);
await page.getByRole("button", { name: /about/i }).click();
// Exact match: the license description paragraph also contains "AGPLv3",
// so a substring match would resolve to two elements (strict mode).
await expect(page.getByText("AGPLv3", { exact: true })).toBeVisible();
});
test("shows SnapOtter logo element", async ({ loggedInPage: page }) => {
await openSettings(page);
await page.getByRole("button", { name: /about/i }).click();
// The OtterLogo component renders an <img> (logo.png), not an inline SVG.
const logo = page.locator("img.text-primary").first();
await expect(logo).toBeVisible();
});
test("GitHub link points to snapotter-hq repository", async ({ loggedInPage: page }) => {
await openSettings(page);
await page.getByRole("button", { name: /about/i }).click();
const githubLink = page.getByRole("link", { name: /github repository/i });
await expect(githubLink).toHaveAttribute("href", "https://github.com/snapotter-hq/snapotter");
});
test("documentation link points to docs.snapotter.com", async ({ loggedInPage: page }) => {
await openSettings(page);
await page.getByRole("button", { name: /about/i }).click();
const docsLink = page.getByRole("link", { name: /documentation/i });
await expect(docsLink).toHaveAttribute("href", "https://docs.snapotter.com/");
});
test("API Reference link points to /api/docs", async ({ loggedInPage: page }) => {
await openSettings(page);
await page.getByRole("button", { name: /about/i }).click();
const apiLink = page.getByRole("link", { name: /api reference/i });
await expect(apiLink).toHaveAttribute("href", "/api/docs");
});
test("external links open in new tab", async ({ loggedInPage: page }) => {
await openSettings(page);
await page.getByRole("button", { name: /about/i }).click();
const githubLink = page.getByRole("link", { name: /github repository/i });
await expect(githubLink).toHaveAttribute("target", "_blank");
await expect(githubLink).toHaveAttribute("rel", /noopener/);
});
test("shows License label and description", async ({ loggedInPage: page }) => {
await openSettings(page);
await page.getByRole("button", { name: /about/i }).click();
await expect(page.getByText("License:").first()).toBeVisible();
});
});
// ===========================================================================
// PEOPLE TAB -- team assignment during user creation
// ===========================================================================
test.describe("Settings People Tab - Team assignment", () => {
test("team dropdown in add form contains Default team", async ({ loggedInPage: page }) => {
await openSettings(page);
await page.getByRole("button", { name: /people/i }).click();
await page.waitForTimeout(500);
await page.getByRole("button", { name: /add members/i }).click();
await expect(page.getByPlaceholder("Username")).toBeVisible();
// The team dropdown is the second select in the form
const teamSelect = page.locator("form select").nth(1);
await expect(teamSelect).toBeVisible();
// Default team should be an option
await expect(teamSelect.locator("option")).toContainText(["Default"]);
// Cancel the form
await page.getByRole("button", { name: /cancel/i }).click();
});
test("Generate Password button populates the password field", async ({ loggedInPage: page }) => {
await openSettings(page);
await page.getByRole("button", { name: /people/i }).click();
await page.waitForTimeout(500);
await page.getByRole("button", { name: /add members/i }).click();
await expect(page.getByPlaceholder("Username")).toBeVisible();
// Click the Generate Password button
const generateBtn = page.getByRole("button", { name: /generate/i });
await expect(generateBtn).toBeVisible();
await generateBtn.click();
// The password field should now have a value (16 chars generated password).
// After generating, the password input flips to type="text", so a generic
// form input[type='text'] selector would also match the (empty) username
// field. Target the password input by its id instead.
const pwInput = page.locator("#new-user-password");
const value = await pwInput.inputValue();
expect(value.length).toBeGreaterThanOrEqual(8);
// Cancel the form
await page.getByRole("button", { name: /cancel/i }).click();
});
test("creating user with specific team assigns them correctly", async ({
loggedInPage: page,
}) => {
const teamName = `exteam-${UID}`;
const username = `extuser-${UID}`;
const adminToken = await getAdminToken();
try {
// Create a team via API first
await fetch(`${API}/api/v1/teams`, {
method: "POST",
headers: authJson(adminToken),
body: JSON.stringify({ name: teamName }),
});
await openSettings(page);
await page.getByRole("button", { name: /people/i }).click();
await page.waitForTimeout(500);
await page.getByRole("button", { name: /add members/i }).click();
await page.getByPlaceholder("Username").fill(username);
await page.getByPlaceholder("Password").fill("TestPass123!");
// Select the created team
const teamSelect = page.locator("form select").nth(1);
await teamSelect.selectOption(teamName);
await page.getByRole("button", { name: /create/i }).click();
// User should appear in the table with the team name
await expect(page.getByText(username)).toBeVisible({ timeout: 5_000 });
await expect(page.getByText(teamName).first()).toBeVisible();
} finally {
await cleanupUsersByPrefix(adminToken, "extuser-");
await cleanupTeamsByPrefix(adminToken, "exteam-");
}
});
test("Add Members form shows 'New Member' heading", async ({ loggedInPage: page }) => {
await openSettings(page);
await page.getByRole("button", { name: /people/i }).click();
await page.waitForTimeout(500);
await page.getByRole("button", { name: /add members/i }).click();
await expect(page.getByText("New Member")).toBeVisible();
await page.getByRole("button", { name: /cancel/i }).click();
});
test("canceling add form resets it", async ({ loggedInPage: page }) => {
await openSettings(page);
await page.getByRole("button", { name: /people/i }).click();
await page.waitForTimeout(500);
await page.getByRole("button", { name: /add members/i }).click();
await page.getByPlaceholder("Username").fill("shouldnotexist");
// Cancel
await page.getByRole("button", { name: /cancel/i }).click();
// Form should be hidden
await expect(page.getByPlaceholder("Username")).not.toBeVisible();
// Re-open -- the username field should be empty
await page.getByRole("button", { name: /add members/i }).click();
const usernameValue = await page.getByPlaceholder("Username").inputValue();
expect(usernameValue).toBe("");
await page.getByRole("button", { name: /cancel/i }).click();
});
test("user table row has avatar with first letter initial", async ({ loggedInPage: page }) => {
await openSettings(page);
await page.getByRole("button", { name: /people/i }).click();
await page.waitForTimeout(500);
// The admin row should have an avatar with "A" (first letter of admin)
const avatar = page.locator(".w-8.h-8.rounded-full").first();
await expect(avatar).toBeVisible();
await expect(avatar).toContainText("A");
});
test("people tab heading says 'People'", async ({ loggedInPage: page }) => {
await openSettings(page);
await page.getByRole("button", { name: /people/i }).click();
await expect(page.locator("h3").filter({ hasText: "People" })).toBeVisible();
});
});
// ===========================================================================
// TEAMS TAB -- expanded team management tests
// ===========================================================================
test.describe("Settings Teams Tab - Extended", () => {
test("teams tab heading and description", async ({ loggedInPage: page }) => {
await openSettings(page);
await page.getByRole("button", { name: /teams/i }).click();
await expect(page.locator("h3").filter({ hasText: "Teams" })).toBeVisible();
await expect(page.getByText(/manage.*team/i).first()).toBeVisible();
});
test("Create New Team button toggles the form", async ({ loggedInPage: page }) => {
await openSettings(page);
await page.getByRole("button", { name: /teams/i }).click();
// Click to open form
await page.getByRole("button", { name: /create new team/i }).click();
await expect(page.getByPlaceholder("Team name")).toBeVisible();
// Click again to close form
await page.getByRole("button", { name: /create new team/i }).click();
await expect(page.getByPlaceholder("Team name")).not.toBeVisible();
});
test("team three-dot menu shows Rename and Delete options", async ({ loggedInPage: page }) => {
const teamName = `menuteam-${UID}`;
const adminToken = await getAdminToken();
try {
// A fresh instance has no teams, so create one to act on.
await fetch(`${API}/api/v1/teams`, {
method: "POST",
headers: authJson(adminToken),
body: JSON.stringify({ name: teamName }),
});
await openSettings(page);
await page.getByRole("button", { name: /teams/i }).click();
await expect(page.getByText(teamName)).toBeVisible({ timeout: 5_000 });
// Open the three-dot menu for the team
const moreButtons = page.locator("button:has(svg.lucide-ellipsis-vertical)");
await moreButtons.last().click();
await expect(page.locator("[role='menu']").getByText("Rename")).toBeVisible();
await expect(page.locator("[role='menu']").getByText("Delete")).toBeVisible();
} finally {
await cleanupTeamsByPrefix(adminToken, "menuteam-");
}
});
test("team rename via Enter key works", async ({ loggedInPage: page }) => {
const teamName = `enterteam-${UID}`;
const renamedName = `enterrenamed-${UID}`;
const adminToken = await getAdminToken();
try {
// Create a team via API
await fetch(`${API}/api/v1/teams`, {
method: "POST",
headers: authJson(adminToken),
body: JSON.stringify({ name: teamName }),
});
await openSettings(page);
await page.getByRole("button", { name: /teams/i }).click();
await page.waitForTimeout(500);
await expect(page.getByText(teamName)).toBeVisible({ timeout: 5_000 });
// Open menu and rename
const moreButtons = page.locator("button:has(svg.lucide-ellipsis-vertical)");
await moreButtons.last().click();
await page.locator("[role='menu']").getByText("Rename").click();
// Inline edit input should appear
const renameInput = page.locator(
"input.px-2.py-1.rounded.border.border-border.bg-background",
);
await expect(renameInput).toBeVisible({ timeout: 3_000 });
await renameInput.fill(renamedName);
await renameInput.press("Enter");
// Updated name should appear
await expect(page.getByText(renamedName)).toBeVisible({ timeout: 5_000 });
} finally {
await cleanupTeamsByPrefix(adminToken, "enterteam-");
await cleanupTeamsByPrefix(adminToken, "enterrenamed-");
}
});
test("team rename cancel via Escape key works", async ({ loggedInPage: page }) => {
const teamName = `escteam-${UID}`;
const adminToken = await getAdminToken();
try {
await fetch(`${API}/api/v1/teams`, {
method: "POST",
headers: authJson(adminToken),
body: JSON.stringify({ name: teamName }),
});
await openSettings(page);
await page.getByRole("button", { name: /teams/i }).click();
await page.waitForTimeout(500);
await expect(page.getByText(teamName)).toBeVisible({ timeout: 5_000 });
// Open the team menu and click Rename
const moreButtons = page.locator("button:has(svg.lucide-ellipsis-vertical)");
await moreButtons.last().click();
await page.locator("[role='menu']").getByText("Rename").click();
// Inline edit input should appear
const renameInput = page.locator(
"input.px-2.py-1.rounded.border.border-border.bg-background",
);
await expect(renameInput).toBeVisible({ timeout: 3_000 });
// Press Escape to cancel
await renameInput.press("Escape");
// Input should disappear and the team name should be unchanged
await expect(renameInput).not.toBeVisible();
await expect(page.getByText(teamName)).toBeVisible();
} finally {
await cleanupTeamsByPrefix(adminToken, "escteam-");
}
});
test("creating multiple teams shows correct member counts", async ({ loggedInPage: page }) => {
const teamName1 = `multi1-${UID}`;
const teamName2 = `multi2-${UID}`;
const adminToken = await getAdminToken();
try {
await fetch(`${API}/api/v1/teams`, {
method: "POST",
headers: authJson(adminToken),
body: JSON.stringify({ name: teamName1 }),
});
await fetch(`${API}/api/v1/teams`, {
method: "POST",
headers: authJson(adminToken),
body: JSON.stringify({ name: teamName2 }),
});
await openSettings(page);
await page.getByRole("button", { name: /teams/i }).click();
await page.waitForTimeout(500);
await expect(page.getByText(teamName1)).toBeVisible({ timeout: 5_000 });
await expect(page.getByText(teamName2)).toBeVisible({ timeout: 5_000 });
} finally {
await cleanupTeamsByPrefix(adminToken, "multi1-");
await cleanupTeamsByPrefix(adminToken, "multi2-");
}
});
});
// ===========================================================================
// ROLES TAB -- expanded custom role tests
// ===========================================================================
test.describe("Settings Roles Tab - Extended custom role management", () => {
test("creating a role without a name is prevented", async ({ loggedInPage: page }) => {
await openSettings(page);
await page.getByRole("button", { name: /^roles$/i }).click();
await page.getByRole("button", { name: /create custom role/i }).click();
// Leave name empty and try to submit
const nameInput = page.getByPlaceholder("Role name");
await expect(nameInput).toHaveAttribute("required", "");
// Cancel
await page.getByRole("button", { name: /cancel/i }).click();
});
test("creating a custom role with duplicate name shows error", async ({ loggedInPage: page }) => {
const roleName = `exdup${UID}`;
const adminToken = await getAdminToken();
try {
// Create role via API first
await fetch(`${API}/api/v1/roles`, {
method: "POST",
headers: authJson(adminToken),
body: JSON.stringify({
name: roleName,
description: "Dup test",
permissions: ["tools:use"],
}),
});
await openSettings(page);
await page.getByRole("button", { name: /^roles$/i }).click();
await expect(page.getByText(roleName)).toBeVisible({ timeout: 5_000 });
// Try to create same role via GUI
await page.getByRole("button", { name: /create custom role/i }).click();
await page.getByPlaceholder("Role name").fill(roleName);
await page.getByRole("button", { name: /^create$/i }).click();
// Should show duplicate error
await expect(page.getByText(/already exists|duplicate/i).first()).toBeVisible({
timeout: 5_000,
});
} finally {
await cleanupRolesByPrefix(adminToken, roleName);
}
});
test("admin built-in role shows correct permissions", async ({ loggedInPage: page }) => {
await openSettings(page);
await page.getByRole("button", { name: /^roles$/i }).click();
// Admin role should have all permissions displayed
// Look for key permissions that admin role must have
await expect(
page.locator(".font-mono").filter({ hasText: "users:manage" }).first(),
).toBeVisible();
await expect(
page.locator(".font-mono").filter({ hasText: "settings:write" }).first(),
).toBeVisible();
});
test("Cancel button on edit role form closes it", async ({ loggedInPage: page }) => {
const roleName = `excancel${UID}`;
const adminToken = await getAdminToken();
try {
await fetch(`${API}/api/v1/roles`, {
method: "POST",
headers: authJson(adminToken),
body: JSON.stringify({
name: roleName,
description: "Cancel test",
permissions: ["tools:use"],
}),
});
await openSettings(page);
await page.getByRole("button", { name: /^roles$/i }).click();
await expect(page.getByText(roleName)).toBeVisible({ timeout: 5_000 });
// Open edit
await page.locator("button[title='Edit role']").first().click();
await expect(page.getByText(/edit role/i)).toBeVisible();
// Cancel
await page.getByRole("button", { name: /cancel/i }).click();
// Edit form should be hidden
await expect(page.getByText(/edit role/i)).not.toBeVisible();
} finally {
await cleanupRolesByPrefix(adminToken, roleName);
}
});
test("editing a custom role description updates it", async ({ loggedInPage: page }) => {
const roleName = `exdesc${UID}`;
const adminToken = await getAdminToken();
try {
await fetch(`${API}/api/v1/roles`, {
method: "POST",
headers: authJson(adminToken),
body: JSON.stringify({
name: roleName,
description: "Original desc",
permissions: ["tools:use"],
}),
});
await openSettings(page);
await page.getByRole("button", { name: /^roles$/i }).click();
await expect(page.getByText(roleName)).toBeVisible({ timeout: 5_000 });
// Edit the role
await page.locator("button[title='Edit role']").first().click();
await expect(page.getByText(/edit role/i)).toBeVisible();
// Change description
const descInput = page.getByPlaceholder("Description (optional)");
await descInput.fill("Updated desc");
// Save
await page.getByRole("button", { name: /^save$/i }).click();
await expect(page.getByText("Role updated")).toBeVisible({ timeout: 5_000 });
// Updated description should appear on the card
await expect(page.getByText("Updated desc")).toBeVisible();
} finally {
await cleanupRolesByPrefix(adminToken, roleName);
}
});
});
// ===========================================================================
// AUDIT LOG TAB -- expanded tests
// ===========================================================================
test.describe("Settings Audit Log Tab - Extended", () => {
test("audit log pagination controls appear when entries exceed page limit", async ({
loggedInPage: page,
}) => {
await openSettings(page);
await page.getByRole("button", { name: /audit log/i }).click();
// Wait for table to load
await expect(page.locator("h3").filter({ hasText: "Audit Log" })).toBeVisible();
// Pagination controls may or may not appear depending on entry count
// Just verify the component loads without error
await page.waitForTimeout(1_000);
});
test("filter dropdown includes USER_UPDATED and PASSWORD_RESET actions", async ({
loggedInPage: page,
}) => {
await openSettings(page);
await page.getByRole("button", { name: /audit log/i }).click();
const filterSelect = page.locator("select").first();
await expect(filterSelect).toBeVisible();
for (const action of ["USER_UPDATED", "PASSWORD_RESET", "API_KEY_DELETED", "ROLE_CREATED"]) {
await expect(filterSelect.locator(`option[value='${action}']`)).toBeAttached();
}
});
test("USER_CREATED entry appears after creating a user", async ({ loggedInPage: page }) => {
const username = `auditcreate-${UID}`;
const adminToken = await getAdminToken();
try {
// Create a user via API to generate a USER_CREATED audit entry
await fetch(`${API}/api/auth/register`, {
method: "POST",
headers: authJson(adminToken),
body: JSON.stringify({ username, password: "AuditPass123!", role: "user" }),
});
await openSettings(page);
await page.getByRole("button", { name: /audit log/i }).click();
await expect(page.locator("table thead")).toBeVisible({ timeout: 10_000 });
const filterSelect = page.locator("select").first();
await filterSelect.selectOption("USER_CREATED");
await expect(page.locator("table tbody tr").first()).toBeVisible({ timeout: 10_000 });
const tableText = await page.locator("table tbody").textContent();
expect(tableText).toContain("USER_CREATED");
} finally {
await cleanupUsersByPrefix(adminToken, "auditcreate-");
}
});
test("API_KEY_CREATED entry appears after generating an API key", async ({
loggedInPage: page,
}) => {
await openSettings(page);
// Generate an API key to trigger the audit entry
await page.getByRole("button", { name: /api keys/i }).click();
const keyName = `auditkey-${Date.now()}`;
await page.getByPlaceholder("Key name (optional)").fill(keyName);
await page.getByRole("button", { name: /generate api key/i }).click();
await expect(page.locator("code.font-mono")).toBeVisible({ timeout: 5_000 });
// Clean up the key
page.on("dialog", (d) => d.accept());
await page.locator("button[title='Delete key']").first().click();
await page.waitForTimeout(500);
// Check audit log for API_KEY_CREATED
await page.getByRole("button", { name: /audit log/i }).click();
await expect(page.locator("table thead")).toBeVisible({ timeout: 10_000 });
const filterSelect = page.locator("select").first();
await filterSelect.selectOption("API_KEY_CREATED");
await expect(page.locator("table tbody tr").first()).toBeVisible({ timeout: 10_000 });
const tableText = await page.locator("table tbody").textContent();
expect(tableText).toContain("API_KEY_CREATED");
});
});
// ===========================================================================
// API KEYS TAB -- expanded tests
// ===========================================================================
test.describe("Settings API Keys Tab - Extended", () => {
test("API keys tab shows heading and description", async ({ loggedInPage: page }) => {
await openSettings(page);
await page.getByRole("button", { name: /api keys/i }).click();
await expect(page.locator("h3").filter({ hasText: "API Keys" })).toBeVisible();
await expect(page.getByText("Manage API keys for programmatic access")).toBeVisible();
});
test("generating key with expiration date shows expiration in the list", async ({
loggedInPage: page,
}) => {
await openSettings(page);
await page.getByRole("button", { name: /api keys/i }).click();
const keyName = `expTest-${Date.now()}`;
await page.getByPlaceholder("Key name (optional)").fill(keyName);
// Set an expiration date
const dateInput = page.locator("input[type='datetime-local']");
await dateInput.fill("2030-12-31T23:59");
// Generate
await page.getByRole("button", { name: /generate api key/i }).click();
await expect(page.locator("code.font-mono")).toBeVisible({ timeout: 5_000 });
// The key entry should show an expiration indicator
await expect(page.getByText(/Expires/).first()).toBeVisible();
// Clean up
page.on("dialog", (d) => d.accept());
await page.locator("button[title='Delete key']").first().click();
await page.waitForTimeout(500);
});
test("multiple API keys can be created and listed", async ({ loggedInPage: page }) => {
await openSettings(page);
await page.getByRole("button", { name: /api keys/i }).click();
const key1Name = `multi1-${Date.now()}`;
const key2Name = `multi2-${Date.now()}`;
// Create first key
await page.getByPlaceholder("Key name (optional)").fill(key1Name);
await page.getByRole("button", { name: /generate api key/i }).click();
await expect(page.locator("code.font-mono")).toBeVisible({ timeout: 5_000 });
// Create second key
await page.getByPlaceholder("Key name (optional)").fill(key2Name);
await page.getByRole("button", { name: /generate api key/i }).click();
await page.waitForTimeout(1_000);
// Both should appear in the Existing Keys list
await expect(page.getByText(key1Name)).toBeVisible();
await expect(page.getByText(key2Name)).toBeVisible();
// Clean up both
page.on("dialog", (d) => d.accept());
const deleteButtons = page.locator("button[title='Delete key']");
const count = await deleteButtons.count();
for (let i = count - 1; i >= 0; i--) {
const btn = deleteButtons.nth(i);
const isVis = await btn.isVisible().catch(() => false);
if (isVis) {
await btn.click();
await page.waitForTimeout(500);
}
}
});
test("scoped permission checkbox labels include permission names", async ({
loggedInPage: page,
}) => {
await openSettings(page);
await page.getByRole("button", { name: /api keys/i }).click();
await page.getByText("Restrict permissions (optional)").click();
await expect(page.getByText("Remove permission scoping")).toBeVisible();
// Key permissions should be listed as checkbox labels
for (const perm of ["tools:use", "files:own"]) {
await expect(page.getByText(perm).first()).toBeVisible();
}
// Collapse
await page.getByText("Remove permission scoping").click();
await expect(page.getByText("Restrict permissions (optional)")).toBeVisible();
});
});
// ===========================================================================
// AI FEATURES TAB -- expanded tests
// ===========================================================================
test.describe("Settings AI Features Tab - Extended", () => {
test("AI Features tab shows correct heading", async ({ loggedInPage: page }) => {
await openSettings(page);
await page.getByRole("button", { name: /ai features/i }).click();
await expect(page.locator("h3").filter({ hasText: "AI Features" })).toBeVisible();
});
test("AI Features tab shows Install All button", async ({ loggedInPage: page }) => {
await openSettings(page);
await page.getByRole("button", { name: /ai features/i }).click();
await expect(page.getByRole("button", { name: /install all/i })).toBeVisible();
});
test("each AI bundle card shows a name", async ({ loggedInPage: page }) => {
await openSettings(page);
await page.getByRole("button", { name: /ai features/i }).click();
// Wait for bundles to load
const bundleCards = page.locator(".rounded-lg.border.border-border.p-4");
await expect(bundleCards.first()).toBeVisible({ timeout: 5_000 });
// First bundle card should have text content (bundle name)
const firstCardText = await bundleCards.first().textContent();
expect(firstCardText?.length).toBeGreaterThan(0);
});
});
// ===========================================================================
// TOOLS TAB -- expanded tests
// ===========================================================================
test.describe("Settings Tools Tab - Extended", () => {
test("tools section has a search input", async ({ loggedInPage: page }) => {
await openSettings(page);
await page.getByRole("button", { name: /tools/i }).click();
await expect(page.getByText(/\d+ tools? disabled/)).toBeVisible({ timeout: 5_000 });
const dialogContent = page.locator(".flex-1.overflow-y-auto");
const searchInput = dialogContent.getByPlaceholder("Search tools...");
await expect(searchInput).toBeVisible();
});
test("search for a specific tool shows only matching results", async ({ loggedInPage: page }) => {
await openSettings(page);
await page.getByRole("button", { name: /tools/i }).click();
await expect(page.getByText(/\d+ tools? disabled/)).toBeVisible({ timeout: 5_000 });
const dialogContent = page.locator(".flex-1.overflow-y-auto");
const searchInput = dialogContent.getByPlaceholder("Search tools...");
// Search for "Compress"
await searchInput.fill("Compress");
// Compress tool should be visible
await expect(dialogContent.getByText("Compress").first()).toBeVisible();
// Clear search
await searchInput.fill("");
});
test("saving tool settings shows restart banner", async ({ loggedInPage: page }) => {
await openSettings(page);
await page.getByRole("button", { name: /tools/i }).click();
await expect(page.getByText(/\d+ tools? disabled/)).toBeVisible({ timeout: 5_000 });
await page.getByRole("button", { name: /save tool settings/i }).click();
await expect(page.getByText("Restart required for changes to take effect.")).toBeVisible({
timeout: 5_000,
});
});
test("tool descriptions are visible in the tool list", async ({ loggedInPage: page }) => {
await openSettings(page);
await page.getByRole("button", { name: /tools/i }).click();
await expect(page.getByText(/\d+ tools? disabled/)).toBeVisible({ timeout: 5_000 });
// Each tool row has a description in text-xs text-muted-foreground
const descriptions = page.locator(".flex-1.overflow-y-auto .text-xs.text-muted-foreground");
const count = await descriptions.count();
expect(count).toBeGreaterThan(5);
});
});
// ===========================================================================
// RBAC VERIFICATION -- expanded cross-role API endpoint checks
// ===========================================================================
base.describe("RBAC API - Editor expanded endpoint checks", () => {
const EDITOR_EXP = `rbaceditorexp-${UID}`;
const EDITOR_EXP_PASS = "EditorExpPass1";
let adminToken: string;
base.beforeAll(async () => {
adminToken = await getAdminToken();
await createReadyUser(adminToken, EDITOR_EXP, EDITOR_EXP_PASS, "editor");
});
base.afterAll(async () => {
await deleteUser(adminToken, EDITOR_EXP);
});
base.test("editor gets 403 on DELETE user endpoint", async ({ page }) => {
await login(page, EDITOR_EXP, EDITOR_EXP_PASS);
const token = await page.evaluate(() => localStorage.getItem("snapotter-token"));
expect(token).toBeTruthy();
// Try to delete a user (even a fake ID)
const res = await fetch(`${API}/api/auth/users/99999`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
expect(res.status).toBe(403);
});
base.test("editor gets 403 on PUT settings endpoint", async ({ page }) => {
await login(page, EDITOR_EXP, EDITOR_EXP_PASS);
const token = await page.evaluate(() => localStorage.getItem("snapotter-token"));
expect(token).toBeTruthy();
const res = await fetch(`${API}/api/v1/settings`, {
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ fileUploadLimitMb: "999" }),
});
expect(res.status).toBe(403);
});
base.test("editor gets 403 on POST roles endpoint", async ({ page }) => {
await login(page, EDITOR_EXP, EDITOR_EXP_PASS);
const token = await page.evaluate(() => localStorage.getItem("snapotter-token"));
expect(token).toBeTruthy();
const res = await fetch(`${API}/api/v1/roles`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ name: "hacked-role", permissions: ["users:manage"] }),
});
expect(res.status).toBe(403);
});
base.test("editor can access own session endpoint", async ({ page }) => {
await login(page, EDITOR_EXP, EDITOR_EXP_PASS);
const token = await page.evaluate(() => localStorage.getItem("snapotter-token"));
expect(token).toBeTruthy();
const res = await fetch(`${API}/api/auth/session`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.user.username).toBe(EDITOR_EXP);
expect(data.user.role).toBe("editor");
});
});
base.describe("RBAC API - User expanded endpoint checks", () => {
const USER_EXP = `rbacuserexp-${UID}`;
const USER_EXP_PASS = "UserExpPass1";
let adminToken: string;
base.beforeAll(async () => {
adminToken = await getAdminToken();
await createReadyUser(adminToken, USER_EXP, USER_EXP_PASS, "user");
});
base.afterAll(async () => {
await deleteUser(adminToken, USER_EXP);
});
base.test("user gets 403 on DELETE user endpoint", async ({ page }) => {
await login(page, USER_EXP, USER_EXP_PASS);
const token = await page.evaluate(() => localStorage.getItem("snapotter-token"));
expect(token).toBeTruthy();
const res = await fetch(`${API}/api/auth/users/99999`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
expect(res.status).toBe(403);
});
base.test("user gets 403 on POST teams endpoint", async ({ page }) => {
await login(page, USER_EXP, USER_EXP_PASS);
const token = await page.evaluate(() => localStorage.getItem("snapotter-token"));
expect(token).toBeTruthy();
const res = await fetch(`${API}/api/v1/teams`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ name: "hacked-team" }),
});
expect(res.status).toBe(403);
});
base.test("user gets 403 on POST roles endpoint", async ({ page }) => {
await login(page, USER_EXP, USER_EXP_PASS);
const token = await page.evaluate(() => localStorage.getItem("snapotter-token"));
expect(token).toBeTruthy();
const res = await fetch(`${API}/api/v1/roles`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ name: "hacked-role", permissions: ["users:manage"] }),
});
expect(res.status).toBe(403);
});
base.test("user gets 403 on PUT settings endpoint", async ({ page }) => {
await login(page, USER_EXP, USER_EXP_PASS);
const token = await page.evaluate(() => localStorage.getItem("snapotter-token"));
expect(token).toBeTruthy();
const res = await fetch(`${API}/api/v1/settings`, {
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ fileUploadLimitMb: "999" }),
});
expect(res.status).toBe(403);
});
base.test("user can change own password via API", async ({ page }) => {
await login(page, USER_EXP, USER_EXP_PASS);
const token = await page.evaluate(() => localStorage.getItem("snapotter-token"));
expect(token).toBeTruthy();
// Change password to same value
const res = await fetch(`${API}/api/auth/change-password`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
currentPassword: USER_EXP_PASS,
newPassword: USER_EXP_PASS,
}),
});
expect(res.status).toBe(200);
});
base.test("user can generate own API key", async ({ page }) => {
await login(page, USER_EXP, USER_EXP_PASS);
const token = await page.evaluate(() => localStorage.getItem("snapotter-token"));
expect(token).toBeTruthy();
const res = await fetch(`${API}/api/v1/api-keys`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ name: "user-test-key" }),
});
expect(res.status).toBe(201);
// Clean up: list and delete the key
const listRes = await fetch(`${API}/api/v1/api-keys`, {
headers: { Authorization: `Bearer ${token}` },
});
const listData = await listRes.json();
for (const k of listData.apiKeys) {
if (k.name === "user-test-key") {
await fetch(`${API}/api/v1/api-keys/${k.id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
}
}
});
});
// ===========================================================================
// RBAC -- Admin GUI-level tests for admin-only tabs
// ===========================================================================
base.describe("RBAC GUI - Admin admin-only tab deep access", () => {
// The run-scoped auth file, not the pre-isolation .playwright path. A stale
// path leaves the block signed out, so every case here drove the login page.
base.use({ storageState: authFile });
base.test("admin can open Roles tab and see Create Custom Role button", async ({ page }) => {
await page.goto("/");
await openSettings(page);
await page.getByRole("button", { name: /^roles$/i }).click();
await expect(page.locator("h3").filter({ hasText: "Roles" })).toBeVisible();
await expect(page.getByRole("button", { name: /create custom role/i })).toBeVisible();
});
base.test("admin can open Audit Log and filter by specific action", async ({ page }) => {
await page.goto("/");
await openSettings(page);
await page.getByRole("button", { name: /audit log/i }).click();
await expect(page.locator("h3").filter({ hasText: "Audit Log" })).toBeVisible();
const filterSelect = page.locator("select").first();
await filterSelect.selectOption("LOGIN_SUCCESS");
// Wait for filtered results
await page.waitForTimeout(1_000);
});
base.test("admin can save system settings without error", async ({ page }) => {
await page.goto("/");
await openSettings(page);
await page.getByRole("button", { name: /system settings/i }).click();
await expect(page.getByText("File Upload Limit (MB)")).toBeVisible();
await page.getByRole("button", { name: /save settings/i }).click();
await expect(page.getByText("Settings saved.")).toBeVisible({ timeout: 5_000 });
});
});
// ===========================================================================
// RBAC -- Editor and User GUI-level tab content verification
// ===========================================================================
base.describe("RBAC GUI - Editor tab content access", () => {
const EDITOR_GUI = `rbacedgui-${UID}`;
const EDITOR_GUI_PASS = "EdGuiPass1";
let adminToken: string;
base.beforeAll(async () => {
adminToken = await getAdminToken();
await createReadyUser(adminToken, EDITOR_GUI, EDITOR_GUI_PASS, "editor");
});
base.afterAll(async () => {
await deleteUser(adminToken, EDITOR_GUI);
});
base.test("editor can change password from Security tab", async ({ page }) => {
await login(page, EDITOR_GUI, EDITOR_GUI_PASS);
await openSettings(page);
await page.getByRole("button", { name: /security/i }).click();
await page.getByPlaceholder("Current Password").fill(EDITOR_GUI_PASS);
await page.getByPlaceholder("New Password").first().fill(EDITOR_GUI_PASS);
await page.getByPlaceholder("Confirm New Password").fill(EDITOR_GUI_PASS);
await page.getByRole("button", { name: /change password/i }).click();
await expect(page.getByText("Password changed successfully")).toBeVisible({ timeout: 5_000 });
});
base.test("editor can generate and delete API key from GUI", async ({ page }) => {
await login(page, EDITOR_GUI, EDITOR_GUI_PASS);
await openSettings(page);
await page.getByRole("button", { name: /api keys/i }).click();
const keyName = `editorKey-${Date.now()}`;
await page.getByPlaceholder("Key name (optional)").fill(keyName);
await page.getByRole("button", { name: /generate api key/i }).click();
await expect(page.locator("code.font-mono")).toBeVisible({ timeout: 5_000 });
await expect(page.getByText(keyName)).toBeVisible();
// Delete the key
page.on("dialog", (d) => d.accept());
await page.locator("button[title='Delete key']").first().click();
await expect(page.getByText(keyName)).not.toBeVisible({ timeout: 5_000 });
});
base.test("editor can view About tab with version info", async ({ page }) => {
await login(page, EDITOR_GUI, EDITOR_GUI_PASS);
await openSettings(page);
await page.getByRole("button", { name: /about/i }).click();
await expect(page.locator("h3").filter({ hasText: "About" })).toBeVisible();
await expect(page.getByText("Version:")).toBeVisible();
await expect(page.getByText("SnapOtter").first()).toBeVisible();
});
});
base.describe("RBAC GUI - User tab content access", () => {
const USER_GUI = `rbacusrgui-${UID}`;
const USER_GUI_PASS = "UsrGuiPass1";
let adminToken: string;
base.beforeAll(async () => {
adminToken = await getAdminToken();
await createReadyUser(adminToken, USER_GUI, USER_GUI_PASS, "user");
});
base.afterAll(async () => {
await deleteUser(adminToken, USER_GUI);
});
base.test("user can save General tab preferences", async ({ page }) => {
await login(page, USER_GUI, USER_GUI_PASS);
await openSettings(page);
await expect(page.getByText("Default Tool View")).toBeVisible();
await page.getByRole("button", { name: /save settings/i }).click();
await expect(page.getByText("Settings saved.")).toBeVisible({ timeout: 5_000 });
});
base.test("user can change own password from Security tab", async ({ page }) => {
await login(page, USER_GUI, USER_GUI_PASS);
await openSettings(page);
await page.getByRole("button", { name: /security/i }).click();
await page.getByPlaceholder("Current Password").fill(USER_GUI_PASS);
await page.getByPlaceholder("New Password").first().fill(USER_GUI_PASS);
await page.getByPlaceholder("Confirm New Password").fill(USER_GUI_PASS);
await page.getByRole("button", { name: /change password/i }).click();
await expect(page.getByText("Password changed successfully")).toBeVisible({ timeout: 5_000 });
});
base.test("user cannot access the Tools tab (requires settings:write)", async ({ page }) => {
await login(page, USER_GUI, USER_GUI_PASS);
await openSettings(page);
await expect(page.getByRole("button", { name: /general/i })).toBeVisible();
// The Tools tab writes the admin-only /v1/settings endpoint, so it is gated
// behind settings:write and hidden from the user role.
await expect(page.getByRole("button", { name: /tools/i })).not.toBeVisible();
});
base.test("user can generate API key from GUI", async ({ page }) => {
await login(page, USER_GUI, USER_GUI_PASS);
await openSettings(page);
await page.getByRole("button", { name: /api keys/i }).click();
const keyName = `userKey-${Date.now()}`;
await page.getByPlaceholder("Key name (optional)").fill(keyName);
await page.getByRole("button", { name: /generate api key/i }).click();
await expect(page.locator("code.font-mono")).toBeVisible({ timeout: 5_000 });
await expect(page.getByText("Store this key securely")).toBeVisible();
// Clean up
page.on("dialog", (d) => d.accept());
await page.locator("button[title='Delete key']").first().click();
await page.waitForTimeout(500);
});
});